From aed134d817b4783b6ca905031c3180b7a8ed1e23 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 26 Jul 2026 16:39:53 +0300 Subject: [PATCH] Complete name-first 1C adapter saved-state support --- config/1c_metadata_kind_fixtures.json | 143 + .../deploy/docker-gpu/adapter-1c/.env.example | 6 + docs/1c-adapter-api-contract.md | 324 +- docs/1c-extension-layer-plan.md | 7 +- docs/runbooks/1c-designer-sql-decoding.md | 62 +- docs/runbooks/1c-repository-layer-policy.md | 179 + docs/runbooks/adapter-1c-mcp.md | 42 +- plugins/1c/connector/.env.example | 10 + plugins/1c/connector/README.md | 40 +- plugins/1c/connector/adapter_1c_server.py | 7874 +++++++++++++++-- plugins/1c/connector/admin/app.js | 12 +- plugins/1c/connector/admin/index.html | 7 +- plugins/1c/connector/contracts/openapi.yaml | 272 +- plugins/1c/connector/repository_control.py | 350 +- plugins/1c/mcp/adapter_1c_mcp.py | 125 +- plugins/1c/parser/README.md | 8 + plugins/1c/parser/__init__.py | 38 +- plugins/1c/parser/common_command.py | 89 + plugins/1c/parser/dbnames.py | 42 + plugins/1c/parser/scheduled_job.py | 451 + plugins/1c/parser/support_rules.py | 140 + scripts/check_1c_connector_standalone.py | 30 + scripts/check_1c_extension_action_contract.py | 10 +- scripts/check_1c_mcp_adapter_contract.py | 41 + scripts/check_1c_metadata_kind_fixtures.py | 484 + scripts/check_powershell_scripts.py | 4 + scripts/deploy_1c_adapter_stack.ps1 | 4 + scripts/export_1c_extension_sources.ps1 | 75 + scripts/smoke_1c_saved_state_changes.py | 12 +- scripts/smoke_1c_saved_state_diff.py | 18 +- scripts/smoke_1c_write_plan_safety.py | 28 +- scripts/smoke_1c_write_preflight.py | 47 +- tests/1c/test_adapter_sql_admin.py | 51 + tests/1c/test_common_command_parser.py | 71 + tests/1c/test_deploy_1c_adapter_security.py | 9 + tests/1c/test_metadata_kind_fixtures.py | 167 + tests/1c/test_object_property_write.py | 584 ++ tests/1c/test_payload_codec.py | 3246 ++++++- tests/1c/test_repository_control.py | 464 +- tests/1c/test_repository_sql_state.py | 119 + tests/1c/test_scheduled_job_parser.py | 71 + tests/1c/test_support_rules.py | 240 + tests/1c/test_write_plan_safety_smoke.py | 74 + tests/1c/test_write_preflight_smoke.py | 63 + 44 files changed, 15436 insertions(+), 697 deletions(-) create mode 100644 config/1c_metadata_kind_fixtures.json create mode 100644 docs/runbooks/1c-repository-layer-policy.md create mode 100644 plugins/1c/parser/common_command.py create mode 100644 plugins/1c/parser/scheduled_job.py create mode 100644 plugins/1c/parser/support_rules.py create mode 100644 scripts/check_1c_metadata_kind_fixtures.py create mode 100644 scripts/export_1c_extension_sources.ps1 create mode 100644 tests/1c/test_common_command_parser.py create mode 100644 tests/1c/test_metadata_kind_fixtures.py create mode 100644 tests/1c/test_object_property_write.py create mode 100644 tests/1c/test_repository_sql_state.py create mode 100644 tests/1c/test_scheduled_job_parser.py create mode 100644 tests/1c/test_support_rules.py create mode 100644 tests/1c/test_write_plan_safety_smoke.py create mode 100644 tests/1c/test_write_preflight_smoke.py diff --git a/config/1c_metadata_kind_fixtures.json b/config/1c_metadata_kind_fixtures.json new file mode 100644 index 0000000..bf12de6 --- /dev/null +++ b/config/1c_metadata_kind_fixtures.json @@ -0,0 +1,143 @@ +{ + "schema": "onec_metadata_kind_fixture_manifest.v1", + "default_base_id": "upo_test", + "required_kinds": [ + "CalculationRegister", + "Sequence", + "Interface" + ], + "policy": { + "provisioning_tool": "1c_designer", + "direct_sql_write_allowed": false, + "apply_to_active_configuration_by_default": false, + "credentials_in_manifest_allowed": false + }, + "designer": { + "required_version": "8.5.1.1236", + "access_mode": "operating_system_integrated_only", + "credential_arguments_allowed": false, + "export_script": "scripts/export_1c_extension_sources.ps1" + }, + "external_reference": { + "repository": "https://github.com/asosnoviy/go1cover.git", + "commit": "c7e3ea3d90396d341cb98285c29973bcc0a82eca", + "source_path": "testresourse/cf/designer/src", + "license": "GPL-3.0", + "usage": "external_reference_only_not_vendored", + "kind_paths": { + "CalculationRegister": "CalculationRegisters/РегистрРасчета1.xml", + "Sequence": "Sequences/Последовательность1.xml", + "Interface": "Interfaces/Интерфейс1.xml" + } + }, + "fixtures": [ + { + "id": "calculation_register_saved_extension", + "kind": "CalculationRegister", + "kind_ru": "РегистрРасчета", + "coverage_before_fixture": "decoder_and_unit_tests", + "target": { + "mode": "extension_saved_state", + "base_id": "upo_test", + "platform_version": "8.5.1.1236", + "extension": "test2", + "state": "working" + }, + "selector": { + "names": [ + "tt_РегистрРасчета1", + "tt_CalcRegister1" + ], + "guid": "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5" + }, + "dependencies": [ + { + "kind": "ChartOfCalculationTypes", + "name": "tt_ВидыРасчета1" + } + ], + "expected_activation_states": [ + "saved_only", + "saved_override", + "active" + ], + "smoke_methods": [ + "metadata.object.get", + "metadata.object.properties", + "metadata.object.attributes", + "metadata.object.related" + ], + "provisioning": { + "operation": "create_or_load_with_designer", + "save_extension": true, + "apply_extension": false, + "rollback": "remove_fixture_objects_with_designer" + } + }, + { + "id": "sequence_saved_extension", + "kind": "Sequence", + "kind_ru": "Последовательность", + "coverage_before_fixture": "generic_decoder_and_unit_discovery", + "target": { + "mode": "extension_saved_state", + "base_id": "upo_test", + "platform_version": "8.5.1.1236", + "extension": "test2", + "state": "working" + }, + "selector": { + "names": [ + "tt_Последовательность1", + "tt_Sequence1" + ] + }, + "dependencies": [], + "expected_activation_states": [ + "saved_only", + "saved_override", + "active" + ], + "smoke_methods": [ + "metadata.object.get", + "metadata.object.properties", + "metadata.object.attributes" + ], + "provisioning": { + "operation": "create_or_load_with_designer", + "save_extension": true, + "apply_extension": false, + "rollback": "remove_fixture_objects_with_designer" + } + }, + { + "id": "interface_legacy_base", + "kind": "Interface", + "kind_ru": "Интерфейс", + "coverage_before_fixture": "root_collection_discovery_only", + "target": { + "mode": "dedicated_base_active", + "base_override_required": true + }, + "selector": { + "names": [ + "tt_Интерфейс1", + "tt_Interface1" + ] + }, + "dependencies": [], + "expected_activation_states": [ + "active" + ], + "smoke_methods": [ + "metadata.object.get" + ], + "provisioning": { + "operation": "load_legacy_configuration_with_designer", + "save_extension": false, + "apply_extension": false, + "rollback": "discard_dedicated_test_base" + } + } + ] +} diff --git a/core/deploy/docker-gpu/adapter-1c/.env.example b/core/deploy/docker-gpu/adapter-1c/.env.example index e32e9cf..892f36b 100644 --- a/core/deploy/docker-gpu/adapter-1c/.env.example +++ b/core/deploy/docker-gpu/adapter-1c/.env.example @@ -9,3 +9,9 @@ ADAPTER_1C_HOST_PORT=8011 ONEC_SQL_BASES_JSON= # Optional path inside the container to a JSON file with the same shape as ONEC_SQL_BASES_JSON. ONEC_SQL_BASES_JSON_FILE=/data/onec-sql-bases.json + +# Adapter-owned local SQLite state. Never point this at the 1C database. +ONEC_ADAPTER_STATE_DB=/data/adapter-cache.sqlite +ONEC_ADAPTER_JOB_PROCESS_ISOLATION=true +ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB=0 +ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS=0 diff --git a/docs/1c-adapter-api-contract.md b/docs/1c-adapter-api-contract.md index eeedbd9..57c5d5f 100644 --- a/docs/1c-adapter-api-contract.md +++ b/docs/1c-adapter-api-contract.md @@ -603,6 +603,22 @@ Important methods: resolved through the platform `_BPr` route internally. - `data.present` returns a compact presentation for one `record_ref`, and `data.movements` reads register rows for a `recorder_ref`. +- BSP access-key methods use the same object/record separation without + overloading their query-area selector. For `access.keys.query`, + `kind=object` selects the access-key area; it is not a metadata kind. Pass + `object_ref=Справочники.Номенклатура` to select the metadata object and + `record_ref=<32-hex-ref>` to select one application-data record. + `access.object_keys.resolve` and `access.object.explain` accept the same + pair. The adapter resolves `object_sql_number` and `object_id` internally. + Raw `object`, `object_id`, and `object_sql_number` remain compatibility + filters only and should not be generated by new MCP clients. +- `metadata.adapter.audit include_unmapped=true` distinguishes configuration + metadata from platform storage. Live `upo_test` DBNames verification showed + the former candidate roles `Acoustic`, `Bots`, `Ecs`, `LangModel`, + `MobileClientDataExchange`, `STT*`, `URLExternalData`, and + `WebSocketClients` as singleton platform-system tables, normally carrying + the zero GUID. `ExtDataSrcPrms` is auxiliary external-data-source storage. + None of these roles is exposed as an invented public metadata kind. - `metadata.object.form.details` decodes form commands with their explicit `Action` value from the SQL form payload. `command_links` resolve that action against the complete form-module routine index; `routines_sample` is only a @@ -722,8 +738,12 @@ Important methods: - `CommonModule.` is a complete public SQL route: root discovery resolves the object by its 1C name, and module APIs read its canonical `.0` Config stream. Live `upo_test` discovery contains 3,400 common modules; sampled - modules decoded as BSL with public routine indexes. Common-module writes, when - explicitly enabled, remain limited to the saved-state layer. + modules decoded as BSL with public routine indexes. In public + `metadata.object.modules`, `modules.read`, and code-search responses, the + module `name`, `qualified_name`, and `display_name` are the 1C common-module + name; Config GUID/stream coordinates are exposed only with + `include_storage=true`. Common-module writes, when explicitly enabled, remain + limited to the saved-state layer. - For `Role`, `metadata.object.properties` reads the separate SQL part `.0` and returns `set_for_new_objects`, `set_for_attributes_by_default`, `independent_rights_of_child_objects`, @@ -752,6 +772,95 @@ Important methods: `allow_full_scan=true`. Accounting-register totals are reported as `unsupported_register` until account and subconto semantics are resolved for the selected register; raw movements remain readable. +- All `data.*` methods publish the same name-first object selector contract in + `help.methods`: callers may pass `object_ref`, `ref`, `kind/name/guid`, or + MCP aliases `object_type/object_name/object_guid`. Prefer `object_ref` with a + 1C code-style name such as `Справочники.Номенклатура` when a method also + needs `record_ref` or `recorder_ref`; a record reference is never treated as + the metadata object name. +- `metadata.support.decode` accepts the same optional public object selectors. + When `ref` or `kind/name` is supplied, the adapter resolves the metadata GUID + internally and returns the effective supplier-support rule for that named + object; callers do not need to discover or pass storage identifiers. +- `metadata.code_index.build` accepts an optional public object selector for a + targeted local-cache refresh. A named base object is resolved through + `Config`, while an extension object with `extension_guid` uses + `ConfigCASSave`; omitting the selector keeps the bounded global `ConfigCAS` + scan. The index remains a local derivative cache and never writes to the 1C + database. +- `metadata.form.owner_index.build` keeps the owner selector and form selector + separate. Use `ref`/`kind/name` for the owning 1C object and `form` or + `form_name` for the nested form. For example, + `ref=Обработки.ОбменДанными, form_name=ФормаОбмена` searches the owner by + `ОбменДанными` and indexes only the named form; the two names are not + substituted for one another. +- `metadata.form.command_button.verify` uses the same owner/form separation and + publishes `command_name`, `button_name`, and `handler_name` as distinct child + selectors. Saved forms with the same name are matched against owner evidence + before verification, so a form belonging to another object is not selected + merely because its form name matches. +- `metadata.form.command_button.write` uses the same disambiguation before it + creates any proposal. `object_guid` identifies the owner and `form_guid` + identifies the nested form; they are never substituted for one another. + Name-first selection does not weaken the write workflow: + `allow_saved_state_write`, repository apply checks, + `allow_sql_saved_state_apply`, verification, and rollback guards remain + mandatory in their existing execution modes. +- `metadata.form.write_target.resolve` and + `metadata.form.write_target.verify` use the same public hierarchy: + `ref`/`kind`/`name` select the form owner, `form_name`/`form_guid` select the + nested form, and `element`/`command`/`attribute` select the child target. + `object_guid` is never treated as a nested `form_guid`. +- `metadata.form.element.write` and `metadata.form.element.write_apply` pass + that hierarchy through unchanged when resolving the concrete saved-state + file. An owner `object_guid` therefore cannot bypass name-first form + resolution. Planning remains opt-in and SQL apply still requires the + existing explicit apply and repository gates. +- `metadata.form.target.move` resolves the owner and nested form before it + resolves `from_element` and `to_element`; structural moves cannot select a + same-named form owned by another object. Its write/apply/rollback gates are + unchanged. +- `metadata.form.write_matrix.build` and `.smoke` use the same owner/form + hierarchy while resolving the saved-state file. The build operation is + read-only; smoke remains restricted to explicit apply-and-rollback. +- `metadata.write_learning.capture_before` and `.capture_after` also accept + the public owner `ref` plus a separate form selector. They write only local + learning artifacts; the selected 1C saved-state file is read, not modified. +- `metadata.saved_state.forms.search` accepts the same owner scope and filters + decoded saved forms by owner evidence before returning them. Its default + response contains the public owner ref, form name, qualified name, and + name-based selectors for matched elements, commands, and attributes. + SQL files, GUID identities, internal brace paths/markers, and low-level + writable-property coordinates are returned only with + `include_storage=true`. `form_guid` remains a separate exact diagnostic + selector. The method is read-only. +- `metadata.saved_state.modules.search` accepts a public module-owner `ref` or + `kind/name/guid`, resolves the internal saved-state prefix itself, and keeps + `file_name`/`stream_index` as opt-in diagnostic selectors. By default its + rows contain only the 1C owner, nested form, module role, qualified name, + source preview, and a semantic selector. SQL tables/files, GUID identities, + hashes, `module_ref`, and low-level write targets are returned only with + `include_storage=true`. Callers do not need to know an owner GUID for + name-first saved-code search. +- `code.write` is the public name-first write facade. It accepts the module + owner through `ref` or `kind/name/guid`, with `routine_name` as a separate + child selector, then resolves the concrete saved-state module internally. + `metadata.module.write_apply` remains the guarded low-level apply operation + for an already resolved `module_ref`. +- `templates.areas.find` accepts a public template ref such as + `Макеты.ПечатнаяФорма`. The template selector, named-area selector + (`area_name`/`area_query`), and low-level direct route are separate MCP + fields; normal callers do not need a storage route. +- `metadata.cache.lookup` accepts a public object `ref` in addition to + `kind/name/guid`. Its result is only a local identity-cache hint; operations + that require current evidence still verify against live SQL. +- `metadata.module_owner_cache.prune` accepts the cached module owner through + a public `ref` or `kind/name` and resolves `owner_guid` internally. Generated + `module_ref` values remain valid for narrow follow-up cleanup. The method + changes only adapter-local SQLite state, never the 1C SQL database; use + `dry_run=true` to inspect the matching count without deleting cache rows. +- For backward compatibility, a legacy bare `guid` without an owner selector + may still identify a form. `object_guid` never gets that legacy treatment. - Logical schema results are cached briefly. `refresh_cache=true` forces a live metadata decode after a configuration change. - `metadata.objects.list`: lists base/effective metadata objects only. It must @@ -800,7 +909,10 @@ Important methods: evidence from `modules.read` so write planning can still distinguish base, saved state, extension, or unresolved CAS references. - `metadata.adapter.audit`: reports recognized metadata kinds, public kind - counts, missing supported kinds, and unmapped DBNames roles. + counts, missing supported kinds, and unmapped DBNames roles. Every + `missing_supported_kinds` item has + `presence_status=supported_absent_in_selected_base`: absence in one + infobase is not reported as absence of adapter implementation. - Base root discovery includes the configuration object itself, command groups, document numerators, external data sources, and integration services. The root collection UUID map is verified against object UUIDs from the XML export @@ -816,6 +928,22 @@ python scripts/audit_1c_adapter_coverage.py --base-id upo_test --output reports/ The script reads the adapter bearer token from `ONEC_ADAPTER_TOKEN` and never reads or prints the SQL password. +Rare-kind regression fixtures for `CalculationRegister`, `Sequence`, and the +legacy `Interface` are checked separately: + +```text +python scripts/check_1c_metadata_kind_fixtures.py --live +``` + +The fixture checker is read-only. It reuses one extension saved-state query for +all fixtures on the same layer and does not force the slower active +`ConfigCAS` full scan. It requires an explicit dedicated-base mapping for +`Interface`. +The manifest pins the required Designer version and an external structural +reference. Use `scripts/export_1c_extension_sources.ps1` to export `test2` +through operating-system integrated authentication; the helper accepts no +infobase user or credential parameters and never writes platform SQL. + For a resumable read-only application-data audit, run the full public chain for one object of every data-bearing metadata kind: @@ -1967,28 +2095,63 @@ Selector rules: `write-rollback-safety-mcp-smoke.json` for MCP. These reports must show the method in `help.methods`, readable write history, and blocked rollback when `allow_sql_saved_state_rollback` is absent. +- `metadata.saved_state.prepare` prepares an object's saved-state working copy. + Prefer a public selector (`ref` or `kind/name`) together with + `layer=base_saved_state|extension_saved_state`. Its default response reports + the selected 1C object, active/saved semantic layers, record counts, and + readiness without exposing database/table names, storage files, hashes, or + row details. `include_storage=true` retains the low-level diagnostic response. + `plan` remains the default and performs no write; `apply` and + `apply_and_verify` still require `allow_sql_saved_state_prepare=true`. +- Every public RPC `next_resolution`/`next_call` entry uses `{method, params}`. + `payload` is reserved for the outer RPC request envelope and internal + apply-hint bodies; it must not be used as the arguments field of a public + follow-up. Saved-state follow-ups use the semantic contract: `layer` plus a + public object `ref` or `kind/name`. Internal `target_table`, `source_table`, + `file_name`, `file_names`, `module_ref`, object GUIDs, and + `include_storage` are not copied into public follow-up payloads. If only an + opaque storage target is known, the follow-up reports `selector_required` + instead of exposing that target. - `metadata.saved_state.diff` is the agent-facing read-only comparison between - a saved-state payload and its active SQL source. It accepts `table` + - `file_name` or `module_ref`, maps `ConfigSave -> Config` and - `ConfigCASSave -> ConfigCAS`, reads both payloads from live SQL, and returns - `changed`/`unchanged`, `needs_prepare`, current hashes, compact text/tree - diff, and `freshness.status=live_sql_verified`. + a saved-state module and its active source. Prefer a public 1C selector such + as `ref=Справочник.Номенклатура` or `kind/name` plus `module_ordinal`; + the adapter resolves the saved-state table and physical file internally. + Generated `module_ref` or `table` + `file_name` remain accepted for chained + tooling. Name-first responses hide storage coordinates unless + `include_storage=true`, and a missing save layer returns a name-first + `metadata.saved_state.prepare` payload. The method maps + `ConfigSave -> Config` and `ConfigCASSave -> ConfigCAS`, reads both payloads + from live SQL, and returns `changed`/`unchanged`, `needs_prepare`, current + hashes, compact text/tree diff, and + `freshness.status=live_sql_verified`. Deployment verification persists REST/MCP smoke reports as `saved-state-diff-smoke.json` and `saved-state-diff-mcp-smoke.json`. -- `metadata.saved_state.status` is the read-only overview for a whole save - layer. It accepts `table=ConfigSave|ConfigCASSave`, compares saved rows with - `Config`/`ConfigCAS` by `FileName`, `PartNo`, size, and SHA1, and reports - counts plus per-file statuses: `changed`, `unchanged`, or `saved_only`. +- `metadata.saved_state.status` is the read-only name-first overview for a whole + save layer. Prefer `layer=base_saved_state|extension_saved_state`; the default + response reports semantic aggregate state and object/record counts without + exposing SQL tables, database names, files, hashes, or internal diff + selectors. `include_storage=true` enables the diagnostic storage view, which + accepts the backward-compatible `table=ConfigSave|ConfigCASSave`, compares + saved rows with their active source by file part, size, and SHA1, and returns + per-file `changed`, `unchanged`, or `saved_only` statuses. - `metadata.saved_state.changes.list` is the read-only pending-change overview - across `ConfigSave` and `ConfigCASSave` by default. It returns changed and - saved-only files with `diff_selector` links, or includes unchanged files when - `include_unchanged=true`. Pass `include_context=true` for best-effort - form/module context by file; the freshness of the pending-change list remains - live SQL verified, while context is explanatory metadata. Pass + across the base and extension saved-state layers. Use the semantic + `layer=base_saved_state|extension_saved_state` filter when only one layer is + needed; the low-level `table` filter is retained for compatibility. Its + default response is name-first: object/form/module context is resolved + automatically, layer names are semantic, and SQL tables/files, GUIDs, hashes, module refs, concrete diff + selectors, low-level actions, and SQL-specific freshness vocabulary are + hidden. Unresolved changes remain in the + counts and are marked `context_unresolved`. Use `include_storage=true` for + the previous per-file diagnostic view with concrete `diff_selector` links. + `include_unchanged=true` also includes unchanged entries. The freshness of + the pending-change list remains live SQL verified, while context is + explanatory metadata. Pass `group_by_context=true` to also return compact `groups` keyed by resolved form/module/object context; grouping uses the same best-effort context enrichment and falls back to per-file groups when context cannot be resolved. - Each group includes compact `selectors`: per-file `diff` selectors, plus + In diagnostic storage mode each group includes compact `selectors`: per-file + `diff` selectors, plus `module_refs` and `write_plan_targets` when the resolved module context exposes them. Groups also include `next_actions`, derived from those selectors, for safe follow-up calls such as inspecting a diff, reading a @@ -2209,9 +2372,12 @@ Purpose: - include `write_plan_evidence.next_resolution.method` and `write_plan_evidence.next_resolution.params` for `metadata.saved_state.modules.search`, so the agent has the next safe lookup - for `module_ref`. The saved-state module search accepts - `object_type`/`object_name`/`object_guid` as public selectors and resolves - names to `owner_guid` when possible. Its matching streams return + for the module. The follow-up uses `ref` or `kind/name` and the semantic + `layer=base_saved_state|extension_saved_state`; it does not expose + `owner_guid` or SQL table names. The saved-state module search also accepts + the object selector aliases and resolves names to storage identities + internally. With the explicit diagnostic + `include_storage=true` option, its matching streams return `write_plan_target` with `module_ref`, `file_name`, `stream_index`, and `expected_sha1` for the concrete `metadata.write.plan` target. This override evidence itself is not a concrete saved-state write route. @@ -2253,6 +2419,111 @@ Output schema: onec_metadata_write.v1 ``` +Standard object identity properties use the same high-level method and public +1C selector: + +```json +{ + "method": "metadata.write", + "payload": { + "base_id": "", + "target": { + "area": "object", + "kind": "Catalog", + "name": "Номенклатура", + "property": "synonym" + }, + "value": "Номенклатура товаров", + "expected_old": "Товары", + "mode": "plan" + } +} +``` + +This route delegates to `metadata.object.property.write`. It resolves the +object GUID and exact saved-state tree path internally, supports only existing +`synonym` locales and scalar `comment`, and preserves the original payload +format. Apply modes retain SHA1/semantic preconditions, backup, readback +verification, and rollback. Object rename and structural collection edits are +not part of this route. + +An existing child metadata identity uses the same method: + +```json +{ + "method": "metadata.object.property.write", + "payload": { + "base_id": "", + "member_ref": "Catalog.Номенклатура.Attribute.Артикул", + "property": "comment", + "value": "Код товара поставщика", + "allow_saved_state_write": true, + "execution_mode": "plan" + } +} +``` + +`member_kind` + `member_name` is also accepted. If the same child name occurs +in several collections, callers must provide the full `member_ref`; the +adapter returns `ambiguous` instead of choosing a GUID. Supported existing +member categories are `Attribute`, `TabularSection`, `Dimension`, and +`Resource`, including Russian aliases. + +Adding one requisite uses a separate clone-based method: + +```json +{ + "method": "metadata.object.member.add", + "payload": { + "base_id": "", + "template_member_ref": "Catalog.Номенклатура.Attribute.Артикул", + "new_member_name": "КодПоставщика", + "new_member_synonym": "Код поставщика", + "allow_saved_state_write": true, + "execution_mode": "plan" + } +} +``` + +The adapter requires an existing `Attribute` template from the same collection, +generates a deterministic GUID internally, rejects duplicate names, clones the +template type/settings, and appends the cloned record to the same declared +collection. The proposal verifies both the appended node and updated declared +count. Apply/verify/rollback use the normal saved-state gates and backup. + +For a tabular-section column, use the complete nested template path: + +```text +Document.АвансовыйОтчет.TabularSection.Запасы.Attribute.Номенклатура +``` + +Name uniqueness and the deterministic GUID seed are scoped to that tabular +section, so identical column names in other table parts do not conflict. + +Scheduled-job schedule planning uses the same method and a public 1C reference: + +```json +{ + "method": "metadata.write", + "payload": { + "base_id": "", + "target": { + "kind": "schedule", + "ref": "РегламентныеЗадания.ОбменДанными" + }, + "schedule": { + "begin_time": "09:00:00", + "week_days": [1, 2, 3, 4, 5] + }, + "allow_saved_state_write": true, + "mode": "plan" + } +} +``` + +This route returns `onec_scheduled_job_schedule_write.v1`, resolves the object +name to its GUID internally, and targets only `.0` in `ConfigSave`. + Purpose: - provide one agent-facing write entry point; @@ -2285,6 +2556,13 @@ Purpose: `metadata.write`; - v1 routes `target.kind=form` to `metadata.form.element.write_apply`; +- v1 routes `target.area=object` to + `metadata.object.property.write` for `synonym` and `comment`; +- v1 routes `target.area=object` with `operation=add_attribute` to + `metadata.object.member.add`; +- v1 routes `target.kind=schedule` to the named scheduled-job schedule writer; + scalar schedule fields and structural `week_days`/`months` resize are + supported through a verified full schedule-tree replacement; - v1 routes saved-state form payload container module refs without `#stream` to the embedded form payload writer when `routine_name` and `routine_text` identify a single routine edit; this path preserves existing leading BSL @@ -2297,8 +2575,8 @@ Purpose: - preserve the same gates as the lower-level form runner: `plan`, `apply`, `apply_and_rollback`, explicit SQL apply/rollback flags, sha1 preconditions, backup, and semantic verification; -- keep future non-form writers behind explicit target routing instead of - ad-hoc direct SQL methods. +- keep remaining writers behind explicit target routing instead of ad-hoc + direct SQL methods. ## Metadata Write Preflight diff --git a/docs/1c-extension-layer-plan.md b/docs/1c-extension-layer-plan.md index 0071736..ca95c22 100644 --- a/docs/1c-extension-layer-plan.md +++ b/docs/1c-extension-layer-plan.md @@ -275,11 +275,12 @@ Current progress: - `metadata.resolve_overrides` now returns `write_plan_evidence`, a ready `metadata.write.plan` fragment carrying the routine name, object selector fields, `target.extension_action`, and `next_resolution.params` for - `metadata.saved_state.modules.search`; saved-state module search now resolves - public `object_type`/`object_name` selectors to owner GUIDs when possible. + `metadata.saved_state.modules.search`; the follow-up uses a public object + `ref`/`kind`/`name` and semantic saved-state `layer`, while the search + resolves storage identities internally. This reduces agent-side field translation while still requiring a concrete saved-state module route. -- `metadata.saved_state.modules.search` now returns +- With `include_storage=true`, `metadata.saved_state.modules.search` returns `streams[].write_plan_target`, a concrete module target with `module_ref`, `file_name`, `stream_index`, and `expected_sha1` for the next `metadata.write.plan` call. diff --git a/docs/runbooks/1c-designer-sql-decoding.md b/docs/runbooks/1c-designer-sql-decoding.md index 8ff9889..dad30b4 100644 --- a/docs/runbooks/1c-designer-sql-decoding.md +++ b/docs/runbooks/1c-designer-sql-decoding.md @@ -62,8 +62,66 @@ independent metadata properties and must be filtered as derived evidence. The `test2` experiment on 2026-07-15 added a minimal calculation register and its required chart of calculation types through Designer, then saved the -extension without applying it. The objects remain intentionally `saved_only` -for adapter regression checks. +extension without applying it. That historical fixture was later removed from +the live saved state. The observations below remain decoder evidence, but they +must not be interpreted as proof that the current `upo_test` still contains the +object. + +The reproducible fixture requirements are declared in +`config/1c_metadata_kind_fixtures.json`. Validate the declaration offline: + +```powershell +python scripts/check_1c_metadata_kind_fixtures.py +``` + +Probe the current adapter and test base: + +```powershell +python scripts/check_1c_metadata_kind_fixtures.py --live +``` + +Use `--require-ready` only as a strict regression gate after the fixtures have +been loaded with 1C Designer. The checker never creates metadata and never +writes platform SQL. `CalculationRegister` and `Sequence` belong in the saved +`test2` extension. Legacy `Interface` requires a dedicated compatible test +base, supplied explicitly as +`--target-base interface_legacy_base=`; it must not be simulated in a +modern configuration by editing `Config`, `ConfigCAS`, or `DBNames`. + +At the 2026-07-26 verification point, the `upo_test` server required platform +`8.5.1.1236`. The exact Designer build is now installed at +`C:\Program Files\1cv8\8.5.1.1236\bin\1cv8.exe`; the previous client-version +blocker is closed. + +Export the current saved `test2` sources through operating-system integrated +authentication only: + +```powershell +.\scripts\export_1c_extension_sources.ps1 ` + -OutputDirectory C:\Temp\upo-test-test2-source +``` + +The export helper verifies the exact executable version, refuses a non-empty +output directory, always passes `/WA+`, and has no user or credential +parameters. It only reads and exports the extension; it does not load, save, or +apply a configuration. + +The current workstation account is not mapped to an infobase user on +`wsr\upo_test`, so the server export stops with +`Пользователь ИБ не идентифицирован`. A disposable local file infobase needs a +client license and stops with `Не найдена лицензия`. Therefore provisioning is +still blocked by execution environment, not by adapter code or platform +version. Continue on a licensed workstation under an already authorized +operating-system identity; do not add a user name or password to scripts, +manifests, or reports, and do not bypass the block with direct SQL. + +An external structural reference is pinned to +`asosnoviy/go1cover@c7e3ea3d90396d341cb98285c29973bcc0a82eca`, +path `testresourse/cf/designer/src`. It contains examples of +`CalculationRegister`, `Sequence`, and legacy `Interface`. The upstream +repository is GPL-3.0; its files are reference input only and are not vendored +into this project. The pinned source and per-kind paths are declared in the +fixture manifest. Observed read-only SQL signatures in `ConfigCASSave`: diff --git a/docs/runbooks/1c-repository-layer-policy.md b/docs/runbooks/1c-repository-layer-policy.md new file mode 100644 index 0000000..2bd8d75 --- /dev/null +++ b/docs/runbooks/1c-repository-layer-policy.md @@ -0,0 +1,179 @@ +# Политика хранилища по слоям конфигурации 1С + +Каждая конфигурация имеет собственную политику: основная конфигурация — слой +`base`, каждое расширение — слой `extension:`. Подключение основной +конфигурации к хранилищу не означает подключения расширения и наоборот. + +Адаптер работает с метаданными 1С только через SQL. Он не считает SQL-таблицы +доказательством нативного захвата объекта в хранилище. + +Заявки на захват и явные ручные подтверждения — это служебные данные адаптера, +а не данные 1С. Они хранятся в `/data/onec-repository-locks.json` в named +volume Docker `adapter-1c-data` и переживают пересоздание контейнера. Это +хранилище не используется для вывода о нативном захвате и не требует записи в +какую-либо базу 1С. + +## Настройка + +В `development_layers` для каждого фактически используемого слоя задаётся +`repository.connection_state`: + +```json +{ + "base": { + "repository": {"mode": "manual", "connection_state": "configured"}, + "support": {"mode": "unknown"} + }, + "extension:86d12be8-086c-11f0-925c-0050568c1b38": { + "repository": {"mode": "none", "connection_state": "not_configured"}, + "support": {"mode": "unknown"} + } +} +``` + +Допустимые состояния: + +| Состояние | Политика записи | +| --- | --- | +| `not_configured` | Слой не подключён к хранилищу; захват не требуется. Ограничения поддержки сохраняются. | +| `configured` | Подключение известно. В SQL-only режиме нужен запрос и явное подтверждение захвата. | +| `unavailable` | Подключение предполагается, но проверить его нельзя; нужен запрос и явное подтверждение захвата. | +| `unknown` | Не делать предположений: запись в слой блокируется до настройки. | + +Для ручного подтверждения всегда передаётся точный объект и явный флаг +`user_confirmed_locked`. Имя пользователя хранилища берётся из +`repository_user` настройки данного слоя. Поэтому при заполненной настройке +его не нужно повторять в каждой заявке или подтверждении. Если клиент всё же +передаёт `confirmed_repository_user`, оно должно совпадать с настроенным +именем. Если имя не настроено, подтверждение попросит его явно. + +## Сохранение факта подключения агентом + +Агент может сохранить подтверждённый факт в служебной конфигурации адаптера +через `repository.layer.connection.set`. Метод не меняет SQL-базу 1С и требует +явного флага подтверждения. + +Основная конфигурация подключена к хранилищу: + +```json +{ + "method": "repository.layer.connection.set", + "payload": { + "base_id": "neft", + "layer_id": "base", + "connection_state": "configured", + "repository_user": "ivanov", + "confirm_repository_connection_change": true + } +} +``` + +Расширение точно не подключено: + +```json +{ + "method": "repository.layer.connection.set", + "payload": { + "base_id": "neft", + "extension_guid": "86d12be8-086c-11f0-925c-0050568c1b38", + "connection_state": "not_configured", + "confirm_repository_connection_change": true + } +} +``` + +При `not_configured` адаптер устанавливает режим слоя `none`. При +`configured` или `unavailable` новый слой по умолчанию получает ручную +политику захвата. Рекомендуется сразу передать постоянный +`repository_user`: он сохраняется только в конфигурации адаптера для этого +слоя и используется во всех последующих ручных подтверждениях. + +## Ручное подтверждение захвата + +После `repository.lock.request` клиент должен использовать возвращённый +`next_call` без самостоятельного подбора имён полей. Поля из +`next_call.params` передаются как RPC `payload`; его эквивалент: + +```json +{ + "method": "repository.lock.confirm", + "payload": { + "base_id": "neft", + "request_id": "rreq-…", + "user_confirmed_locked": true + } +} +``` + +`objects`, `operation` и `layer_id` не передаются повторно: подтверждение +берёт их из неизменяемой сохранённой заявки. Если имя пользователя сохранено +для слоя, оно тоже не передаётся. Успешный ответ содержит `lock_session_id`; +его необходимо передать в preflight и операцию записи. + +Ответ `repository.lock.confirm` содержит готовый `write_context`. Его можно +передать целиком либо верхнеуровневым полем `write_context`, либо алиасом +`repository_lock`; адаптер нормализует поля до проверки захвата: + +```json +{ + "base_id": "neft", + "target": {"module_id": "Config:.0"}, + "repository_lock": {"lock_session_id": "rlock-…", "repository_object": "Document.Имя.Form.ИмяФормы", "layer_id": "base"} +} +``` + +Если одновременно переданы вложенное и верхнеуровневое значение, они должны +совпадать; иначе возвращается `repository_lock_context_conflict`. + +## Проверка + +`repository.layers.audit` обнаруживает основную конфигурацию и расширения из +live SQL и выдаёт для каждого слоя состояние хранилища, общий режим поддержки +и безопасное следующее действие: + +```json +{ + "method": "repository.layers.audit", + "payload": {"base_id": "neft"} +} +``` + +Ключевые действия в ответе: + +- `repository_lock_not_required` — слой явно не подключён к хранилищу; +- `request_and_confirm_repository_capture` — перед записью требуется + подтверждение захвата; +- `configure_repository_connection_state` — слой нельзя менять, пока не + установлен один из статусов подключения. + +Проверка поддержки выполняется отдельно от хранилища. Для конкретного объекта +используйте `metadata.write.preflight`: разрешение объекта определяет его +`origin`, и адаптер применяет политику найденного слоя, а не клиентский выбор +слоя. + +## Захват формы владельца + +Для формы документа, справочника, обработки или отчёта область захвата задаётся +точно, без сворачивания к владельцу: + +```json +{ + "method": "repository.lock.request", + "payload": { + "base_id": "neft", + "objects": ["Document.тл_Планировщик.Form.ФормаДокументаНовая"], + "operation": "modify" + } +} +``` + +До создания заявки `repository.lock.plan` и сама заявка разрешают форму через +live SQL, получают GUID формы и сохраняют точную каноническую область. После +ручного подтверждения эта сессия не даёт права на соседние формы или модуль +владельца. + +Также принимается `Form.`. Для него адаптер проверяет связь формы с +владельцем по live SQL; при отсутствии готовой производной записи владельца +выполняется ограниченный SQL-скан форм поддерживаемых типов владельцев. Если +владелец не найден, планирование и заявка завершаются `not_found`, а не +возвращают ложный `ready`. diff --git a/docs/runbooks/adapter-1c-mcp.md b/docs/runbooks/adapter-1c-mcp.md index d40b2b9..9e95344 100644 --- a/docs/runbooks/adapter-1c-mcp.md +++ b/docs/runbooks/adapter-1c-mcp.md @@ -126,7 +126,10 @@ REST and MCP selector-chain smoke tests. The selector-chain smoke also checks, when a routine name is available from module metadata, that `metadata.resolve_overrides` returns `write_plan_evidence.next_resolution` for `metadata.saved_state.modules.search` with the same `base_id` and routine query, -then follows that resolver. If a saved-state stream exposes `write_plan_target`, +plus a public object `ref`/`kind`/`name` and semantic +`layer=base_saved_state|extension_saved_state`; public follow-ups never require +`ConfigSave`, `ConfigCASSave`, or `owner_guid`. The smoke then follows that +resolver. If a saved-state stream exposes `write_plan_target`, the smoke composes it with `write_plan_evidence.target` and checks the resulting read-only `metadata.write.plan`; when the test base has no saved-state stream, that last composition step records `skipped_no_saved_state_target`. @@ -506,6 +509,23 @@ roles/users can read or write this metadata object". Do not depend on `metadata.objects.list kind=Role`: some live bases do not expose roles as regular metadata objects through that route. +For BSP data-access keys, keep the metadata object and concrete data record +separate: + +```json +{ + "base_id": "upo_test", + "kind": "object", + "object_ref": "Справочники.Номенклатура", + "record_ref": "00112233445566778899AABBCCDDEEFF" +} +``` + +Here `kind=object` is the `access_keys_query` area, not the metadata kind. +`object_ref` is resolved to the internal BSP SQL number and `record_ref` to the +record id. The same public pair works with `access_object_keys_resolve` and +`access_object_explain`; do not invent `object_sql_number` in MCP code. + For BSP role-right checks, `action=write` means add or modify rights. Read-only roles must not be returned for `write`; query `action=read` separately when you need visibility roles. Returned permissions keep `source_fields` from @@ -634,6 +654,26 @@ Agent rules: `metadata.form.command_button.write` for the complete form command + visible button + handler workflow. `code.write` remains a compatibility shortcut for simple module edits. +- For a metadata object's standard `synonym` or `comment`, call generic + `onec_request` with `method=metadata.object.property.write`, a public + `ref`/`kind` + `name`, and `allow_saved_state_write=true`. Prefer + `execution_mode=plan` first. The adapter resolves GUIDs and serialized paths; + MCP callers must not pass SQL tables or file names. Object rename and adding + a new synonym locale are intentionally unsupported. +- For an existing requisite, tabular section, dimension, or resource, add a + public `member_ref` such as + `Catalog.Номенклатура.Attribute.Артикул`. When only + `member_kind` + `member_name` is used and several identities match, preserve + the adapter's `ambiguous` result and retry with the full member path. +- To add a requisite, call `metadata.object.member.add` through `onec_request` + with a full `template_member_ref` from the same object and the new public + name/synonym. Never invent a GUID, Config table, collection path, or type + descriptor in MCP code; the new requisite inherits type/settings from the + named template. +- The same method adds a tabular-section column when + `template_member_ref` contains the full + `...TabularSection..Attribute.` path. Keep the complete path so + duplicate checks and generated identity remain scoped to that table part. - For unresolved module owners, inspect `diagnostics.owner_resolution` and `counts.owner_scan_limit_hit`; narrow by `kind`/`name`/`guid` or increase `owner_scan_limit` before falling back to broader searches. diff --git a/plugins/1c/connector/.env.example b/plugins/1c/connector/.env.example index 65fe510..9f07847 100644 --- a/plugins/1c/connector/.env.example +++ b/plugins/1c/connector/.env.example @@ -28,8 +28,18 @@ ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST= ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED=false ONEC_ADAPTER_CACHE_DB=/data/adapter-cache.sqlite +# Adapter-owned runtime state (jobs, progress, restart diagnostics). This is +# local SQLite and must not point to a 1C SQL database. Defaults to CACHE_DB. +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 ONEC_ADAPTER_BACKUP_DIR=/data/adapter-apply-backups ONEC_ADAPTER_WRITE_LEARNING_DIR=/data/adapter-write-learning ONEC_ADAPTER_JOB_TIMEOUT_SECONDS=240 ONEC_ADAPTER_FULL_TIMEOUT_SECONDS=600 ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS=180 +# Run non-stateful background calls in killable child processes. +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 diff --git a/plugins/1c/connector/README.md b/plugins/1c/connector/README.md index 7886bce..b833cb7 100644 --- a/plugins/1c/connector/README.md +++ b/plugins/1c/connector/README.md @@ -223,6 +223,7 @@ Current live methods: - `metadata.objects.list` - `metadata.object.get` - `metadata.object.properties` +- `metadata.object.property.write` - `metadata.object.decode` - `metadata.object.parts` - `metadata.object.modules` @@ -260,6 +261,13 @@ qualified metadata names such as `Обработка.` or `Document. Client, MCP, and agent code must not add conditions for concrete object names; the adapter owns generic selector normalization. +Saved-state client calls use the same name-first selectors together with +`layer=base_saved_state|extension_saved_state`. SQL tables, file names, GUID +owners, and module handles are diagnostic continuations exposed only with +`include_storage=true`. Every public RPC follow-up is shaped as +`{"method": "...", "params": {...}}`; `payload` is not the arguments field of +`next_call` or `next_resolution`. + Layer write policy: - `Config` and `ConfigCAS` are **active-applied** and must be treated as read-only in adapter workflows. @@ -293,6 +301,26 @@ metadata kind. It selects a kind-specific SQL decoder for `Configuration`, semantic profile. XML exports are analysis evidence only and are never a runtime source for this method or any other adapter method. +`metadata.object.property.write` is the name-first saved-state writer for the +standard identity properties `synonym` and `comment`. Pass a public object +`ref` or `kind` + `name`. For an existing attribute, tabular section, +dimension, or resource, also pass `member_ref` or `member_kind` + +`member_name`; the adapter resolves the exact parent/member GUIDs and +serialized tree path internally. The method supports `plan`, `apply`, +`apply_and_verify`, and `apply_and_rollback`, requires explicit saved-state +gates, and never writes active `Config`/`ConfigCAS`. Renaming an object and +member, adding/removing collection items, and adding a new synonym locale +remain intentionally disabled. + +`metadata.object.member.add` adds one new object requisite or tabular-section +column (`Attribute`) by cloning an existing attribute in the same collection. +The caller passes only +`template_member_ref`, `new_member_name`, and optionally synonym/comment; the +adapter generates the GUID, preserves the template's type/settings, appends to +the exact declared collection, and verifies the new identity after apply. +Arbitrary type construction and deletion are not supported by this first +structural route. + Managed form bodies in base `Config` are resolved from the public form GUID to the sibling `.0` SQL payload. Command-bar buttons expose public command names when their SQL binding points to a common command or a recognized @@ -468,7 +496,17 @@ capabilities). `metadata.adapter.audit` reports recognized metadata kinds, public kind counts, missing supported kinds when `include_missing=true`, and unmapped DBNames roles -when `include_unmapped=true`. +when `include_unmapped=true`. Missing entries are marked +`presence_status=supported_absent_in_selected_base`; this is a statement about +the selected infobase, not a claim that the adapter lacks that kind. + +Use `python scripts/check_1c_metadata_kind_fixtures.py --live` from the +repository root to check the reproducible rare-kind fixtures. The checker does +not write SQL or create metadata. The fixture manifest pins the exact Designer +version and external reference commit. The companion +`scripts/export_1c_extension_sources.ps1` performs a read-only `test2` source +export with operating-system integrated authentication and exposes no +infobase-user or credential arguments. `codec.decode` and `codec.encode` are low-level lossless helpers. A no-op encode from a live source keeps the original bytes exactly; modified text/tree payloads diff --git a/plugins/1c/connector/adapter_1c_server.py b/plugins/1c/connector/adapter_1c_server.py index 007afe4..94eda0d 100644 --- a/plugins/1c/connector/adapter_1c_server.py +++ b/plugins/1c/connector/adapter_1c_server.py @@ -11,7 +11,9 @@ import hashlib import io import json import math +import multiprocessing import os +import queue import re import sqlite3 import sys @@ -23,12 +25,17 @@ import urllib.error import urllib.request import uuid import xml.etree.ElementTree as ET +import zlib from datetime import date, datetime, timezone from decimal import Decimal from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any, Iterable +CONNECTOR_ROOT = Path(__file__).resolve().parent +if str(CONNECTOR_ROOT) not in sys.path: + sys.path.insert(0, str(CONNECTOR_ROOT)) + import repository_control @@ -37,6 +44,18 @@ PARSER_ROOT = Path(__file__).resolve().parents[1] if len(Path(__file__).resolve( if PARSER_ROOT.exists() and str(PARSER_ROOT) not in sys.path: sys.path.insert(0, str(PARSER_ROOT)) +from parser.scheduled_job import ( + SCHEDULED_JOB_INTEGER_RANGES, + SCHEDULED_JOB_WRITABLE_LIST_FIELDS, + SCHEDULED_JOB_WRITABLE_SCALAR_FIELDS, + config_schedule_datetime, + scheduled_job_schedule_layout, + scheduled_job_schedule_rebuild_tree, + scheduled_job_schedule_write_edits, + scheduled_job_sql_schedule, +) +from parser.common_command import common_command_group_guid, index_common_command_groups + STORAGE_TABLES = {"Config", "ConfigSave", "ConfigCAS", "ConfigCASSave", "Params"} SENSITIVE_RESULT_FIELD_RE = re.compile( r"(?:парол|password|телефон|phone|email|e-mail|почт|паспорт|инн|снилс|банк(?:овск)?(?:ий)?счет|card|карта)", @@ -47,6 +66,7 @@ ADAPTER_JOB_LOCK = threading.Lock() ADAPTER_JOB_TTL_SECONDS = 1800 ADAPTER_JOB_STORE_LOADED = False ADAPTER_INSTANCE_ID = uuid.uuid4().hex +ADAPTER_JOB_EVENT_SINK: Any = None BASE_ROOT_METADATA_CACHE: dict[tuple[str, str], dict[str, Any]] = {} BASE_ROOT_METADATA_CACHE_LOCK = threading.Lock() BASE_ROOT_METADATA_CACHE_TTL_SECONDS = 300 @@ -522,6 +542,133 @@ RU_KIND = { "Interface": "Интерфейс", } +ONEC_CODE_ROOT_KIND = { + "Конфигурация": "Configuration", + "Справочники": "Catalog", + "Документы": "Document", + "Перечисления": "Enum", + "РегистрыСведений": "InformationRegister", + "РегистрыНакопления": "AccumulationRegister", + "РегистрыБухгалтерии": "AccountingRegister", + "Отчеты": "Report", + "Обработки": "DataProcessor", + "ОбщиеМодули": "CommonModule", + "ОбщиеФормы": "CommonForm", + "БизнесПроцессы": "BusinessProcess", + "Задачи": "Task", + "Константы": "Constant", + "ПланыВидовХарактеристик": "ChartOfCharacteristicTypes", + "ПланыСчетов": "ChartOfAccounts", + "ПланыВидовРасчета": "ChartOfCalculationTypes", + "ПланыОбмена": "ExchangePlan", + "ЖурналыДокументов": "DocumentJournal", + "НумераторыДокументов": "DocumentNumerator", + "РегламентныеЗадания": "ScheduledJob", + "ОпределяемыеТипы": "DefinedType", + "РегистрыРасчета": "CalculationRegister", + "Последовательности": "Sequence", + "КритерииОтбора": "SelectionCriterion", + "ПодпискиНаСобытия": "EventSubscription", + "Роли": "Role", + "ПараметрыСеанса": "SessionParameter", + "ФункциональныеОпции": "FunctionalOption", + "ПараметрыФункциональныхОпций": "FunctionalOptionsParameter", + "ХранилищаНастроек": "SettingsStorage", + "ОбщиеРеквизиты": "CommonAttribute", + "ОбщиеКоманды": "CommonCommand", + "Подсистемы": "Subsystem", + "Языки": "Language", + "WebСервисы": "WebService", + "HTTPСервисы": "HTTPService", + "WSСсылки": "WSReference", + "ПакетыXDTO": "XDTOPackage", + "ВнешниеИсточникиДанных": "ExternalDataSource", + "СервисыИнтеграции": "IntegrationService", + "ОбщиеМакеты": "CommonTemplate", + "ОбщиеКартинки": "CommonPicture", + "ГруппыКоманд": "CommandGroup", + "Стили": "Style", + "ЭлементыСтиля": "StyleItem", + "Интерфейсы": "Interface", +} +ONEC_CODE_ROOT_BY_KIND = {kind: root for root, kind in ONEC_CODE_ROOT_KIND.items()} + +ONEC_TYPE_QUALIFIER_KIND = { + "СправочникСсылка": "Catalog", + "СправочникОбъект": "Catalog", + "СправочникСписок": "Catalog", + "СправочникВыборка": "Catalog", + "СправочникМенеджер": "Catalog", + "ДокументСсылка": "Document", + "ДокументОбъект": "Document", + "ДокументСписок": "Document", + "ДокументВыборка": "Document", + "ДокументМенеджер": "Document", + "ПеречислениеСсылка": "Enum", + "ПеречислениеСписок": "Enum", + "ПеречислениеМенеджер": "Enum", + "БизнесПроцессСсылка": "BusinessProcess", + "БизнесПроцессОбъект": "BusinessProcess", + "БизнесПроцессСписок": "BusinessProcess", + "БизнесПроцессВыборка": "BusinessProcess", + "БизнесПроцессМенеджер": "BusinessProcess", + "ЗадачаСсылка": "Task", + "ЗадачаОбъект": "Task", + "ЗадачаСписок": "Task", + "ЗадачаВыборка": "Task", + "ЗадачаМенеджер": "Task", + "ПланВидовХарактеристикСсылка": "ChartOfCharacteristicTypes", + "ПланВидовХарактеристикОбъект": "ChartOfCharacteristicTypes", + "ПланВидовХарактеристикСписок": "ChartOfCharacteristicTypes", + "ПланВидовХарактеристикВыборка": "ChartOfCharacteristicTypes", + "ПланВидовХарактеристикМенеджер": "ChartOfCharacteristicTypes", + "ПланСчетовСсылка": "ChartOfAccounts", + "ПланСчетовОбъект": "ChartOfAccounts", + "ПланСчетовСписок": "ChartOfAccounts", + "ПланСчетовВыборка": "ChartOfAccounts", + "ПланСчетовМенеджер": "ChartOfAccounts", + "ПланВидовРасчетаСсылка": "ChartOfCalculationTypes", + "ПланВидовРасчетаОбъект": "ChartOfCalculationTypes", + "ПланВидовРасчетаСписок": "ChartOfCalculationTypes", + "ПланВидовРасчетаВыборка": "ChartOfCalculationTypes", + "ПланВидовРасчетаМенеджер": "ChartOfCalculationTypes", + "ПланОбменаСсылка": "ExchangePlan", + "ПланОбменаОбъект": "ExchangePlan", + "ПланОбменаСписок": "ExchangePlan", + "ПланОбменаВыборка": "ExchangePlan", + "ПланОбменаМенеджер": "ExchangePlan", + "РегистрСведенийМенеджер": "InformationRegister", + "РегистрСведенийМенеджерЗаписи": "InformationRegister", + "РегистрСведенийНаборЗаписей": "InformationRegister", + "РегистрСведенийКлючЗаписи": "InformationRegister", + "РегистрСведенийСписок": "InformationRegister", + "РегистрСведенийВыборка": "InformationRegister", + "РегистрНакопленияМенеджер": "AccumulationRegister", + "РегистрНакопленияНаборЗаписей": "AccumulationRegister", + "РегистрНакопленияКлючЗаписи": "AccumulationRegister", + "РегистрНакопленияСписок": "AccumulationRegister", + "РегистрНакопленияВыборка": "AccumulationRegister", + "РегистрБухгалтерииМенеджер": "AccountingRegister", + "РегистрБухгалтерииНаборЗаписей": "AccountingRegister", + "РегистрБухгалтерииКлючЗаписи": "AccountingRegister", + "РегистрБухгалтерииСписок": "AccountingRegister", + "РегистрБухгалтерииВыборка": "AccountingRegister", + "РегистрРасчетаМенеджер": "CalculationRegister", + "РегистрРасчетаНаборЗаписей": "CalculationRegister", + "РегистрРасчетаКлючЗаписи": "CalculationRegister", + "РегистрРасчетаСписок": "CalculationRegister", + "РегистрРасчетаВыборка": "CalculationRegister", + "КонстантаМенеджер": "Constant", + "КонстантаМенеджерЗначения": "Constant", + "ОтчетОбъект": "Report", + "ОтчетМенеджер": "Report", + "ОбработкаОбъект": "DataProcessor", + "ОбработкаМенеджер": "DataProcessor", + "ЖурналДокументовСписок": "DocumentJournal", + "ЖурналДокументовМенеджер": "DocumentJournal", + "ПоследовательностьМенеджер": "Sequence", +} + REF_TYPE_PRESENTATION_PREFIX = { "Catalog": "СправочникСсылка", "Document": "ДокументСсылка", @@ -561,6 +708,11 @@ METHODS = [ {"name": "health", "transport": "GET /health", "description": "Состояние REST-адаптера и live SQL-доступа."}, {"name": "help.methods", "transport": "GET /methods or POST /rpc", "description": "Список методов адаптера."}, {"name": "repository.status", "transport": "POST /rpc", "description": "Repository configuration and optional read-only availability probe for direct or configured TCP bridge access."}, + {"name": "repository.layers.audit", "transport": "POST /rpc", "description": "Read-only audit of the base configuration and every discovered extension: repository connection state, support state, and the safe next write action."}, + {"name": "repository.layer.connection.set", "transport": "POST /rpc", "description": "Persist the explicit repository connection state for base or one extension in the adapter configuration only. Requires confirm_repository_connection_change=true; never writes a 1C SQL database."}, + {"name": "metadata.support.decode", "transport": "POST /rpc", "description": "Decode ParentConfigurations support rules from live SQL. The base source is resolved through Config/root and an extension source through its complete ConfigCAS manifest. Repository capture is evaluated separately."}, + {"name": "repository.sql_state.snapshot", "transport": "POST /rpc", "description": "Read-only hashes of selected live SQL configuration-state payloads for controlled before/after repository-state experiments. Requires diagnostic=true and never infers a native lock."}, + {"name": "repository.sql_state.diff", "transport": "POST /rpc", "description": "Compare two repository.sql_state.snapshot responses and report changed records and parts. Requires diagnostic=true and never infers a native lock."}, {"name": "repository.lock.plan", "transport": "POST /rpc", "description": "Resolve public 1C object references to repository development-object lock scope without changing repository state."}, {"name": "repository.lock.request", "transport": "POST /rpc", "description": "Create a persisted manual lock request for the exact resolved object scope; it does not modify SQL or claim a repository lock."}, {"name": "repository.lock.request.status", "transport": "POST /rpc", "description": "Read the adapter-side status of a manual repository lock request."}, @@ -581,6 +733,8 @@ METHODS = [ {"name": "metadata.objects.list", "transport": "GET /metadata/objects or POST /rpc", "description": "1C base/effective metadata object list. Does not accept extension filters; use extension.objects.find for extension-scoped objects such as test2. Uses local metadata cache for the normal fast list; pass refresh_cache=true, include_missing=true, only_missing=true, or exact_counts=true for live verification. Requires limit >= 1 and offset >= 0. Missing/unreadable payloads are hidden by default; only_missing=true lists only them. SQL/storage traces are hidden unless include_storage=true."}, {"name": "metadata.object.get", "transport": "GET /metadata/object or POST /rpc", "description": "1C metadata object card. mode must be card or semantic; default card returns compact identity/matches/counts without semantic sections. include_semantic and include_storage must be JSON booleans true/false, string values are invalid. Pass mode=semantic or include_semantic=true only when decoded semantic sections are needed; for реквизиты/табличные части prefer metadata.object.attributes, for full profile prefer metadata.object.full/decode. SQL/storage traces are hidden unless include_storage=true."}, {"name": "metadata.object.properties", "transport": "POST /rpc", "description": "Unified SQL-only semantic property reader for every 1C metadata object. Uses a kind-specific SQL decoder when available and falls back to the generic live semantic profile for all other kinds. Accepts the standard public object selectors. XML exports are never read at runtime."}, + {"name": "metadata.object.property.write", "transport": "POST /rpc", "description": "Name-first saved-state writer for safe scalar identity properties of objects and existing attributes, tabular sections, dimensions, and resources. Supports synonym and comment, never renames or changes collection structure, never writes active Config/ConfigCAS, and requires explicit prepare/apply/rollback gates."}, + {"name": "metadata.object.member.add", "transport": "POST /rpc", "description": "Name-first saved-state structural writer that adds one object requisite or tabular-section column by cloning an existing Attribute template in the same collection. Generates a container-scoped GUID internally, preserves payload formatting, and supports plan/apply/verify/rollback only in ConfigSave/ConfigCASSave."}, {"name": "metadata.object.decode", "transport": "POST /rpc", "description": "1C-facing decoded object profile: identity and semantic sections. evidence_mode controls undecoded payload evidence: none, summary, full, raw. Raw storage offsets are exposed only with include_storage=true."}, {"name": "metadata.object.parts", "transport": "POST /rpc", "description": "1C-facing object part roles: metadata/form/module/template/help. evidence_mode controls undecoded payload evidence: none, summary, full, raw. Physical Config part keys and raw offsets are hidden unless include_storage=true."}, {"name": "metadata.object.modules", "transport": "POST /rpc", "description": "1C-facing BSL module list for a metadata object. include_storage must be a JSON boolean true/false, string values are invalid. Physical module ids are hidden unless include_storage=true."}, @@ -593,21 +747,21 @@ METHODS = [ {"name": "templates.analyze", "transport": "POST /rpc", "description": "Analyze object templates for named areas, parameters, widths, overlaps, cell coverage, likely merge candidates, and MOXCEL decoding capability diagnostics. Use view=summary|structure|full and sections/max_* to keep responses compact."}, {"name": "templates.map", "transport": "POST /rpc", "description": "Compact agent-facing template map. Returns summary structure and analysis for MXL/MOXCEL templates without the full decoded payload lists unless sections/max_* request them."}, {"name": "templates.areas.find", "transport": "POST /rpc", "description": "Find a template by extension/object query or direct route and return decoded named areas with coordinates from current template payloads. Use area_query/area_name/area_occurrence for focused report/print-form maket area lookup; include_coverage=false returns a compact coordinate list."}, - {"name": "metadata.object.commands", "transport": "POST /rpc", "description": "1C-facing commands for a metadata object. Physical record paths are hidden unless include_storage=true."}, + {"name": "metadata.object.commands", "transport": "POST /rpc", "description": "1C-facing commands for a metadata object. CommandGroup selectors resolve the reverse CommonCommand.Group relation by public names; callers do not pass GUIDs. Physical record paths are hidden unless include_storage=true."}, {"name": "metadata.definition.find", "transport": "POST /rpc", "description": "Find where a 1C name is defined: top-level metadata objects, object attributes, tabular-section fields, form attributes/elements/events/commands, templates, commands, BSL routines, and extension definitions from DBNames-Ext/ConfigCAS. Accepts public refs such as Обработка. or Document., plus ref, kind/name/guid, or object_type/object_name/object_guid selectors. Returns public 1C locations, origin as configuration/extension when known, read selectors, and related_selectors for next adapter calls. A single metadata object match is promoted to top-level object. areas=metadata/extensions can work without an object selector; object/form/module areas require an object selector. When an object selector or form is passed and areas is omitted, search is scoped to the selected object/form to avoid a full configuration scan. Default is live verification; pass use_cache=true only when a fast local index is acceptable, or refresh_cache=true to rebuild the index after configuration/extension updates. No SQL/storage details unless include_storage=true."}, {"name": "metadata.route.resolve", "transport": "POST /rpc", "description": "Resolve live ConfigCAS/DBNames routes for extension metadata objects or child objects by extension, query, kind, or GUID."}, - {"name": "metadata.resolve_overrides", "transport": "POST /rpc", "description": "Build a method override chain for a target object/routine. Defaults to state=working; with extension it includes saved ConfigCASSave routine matches before/alongside active metadata and marks source=saved_state. Use state=active/save/both to choose or compare layers. Returns ordered links for configuration + extensions with owners, source area, extension hints, line range, and fallback warnings. Each link includes extension_action: normalized insert_before/insert_after/replace/replace_with_control when evidence exists, or unknown when extension action metadata still must be resolved."}, + {"name": "metadata.resolve_overrides", "transport": "POST /rpc", "description": "Build the discovered routine override chain for a target object/routine. Default configuration_view=effective_working means the logical Designer snapshot (base, saved changes, and extension layers); runtime_applied is executable now and compare returns both. The chain records extension action evidence (insert_before/insert_after/replace/replace_with_control); it never presents raw storage tables as the programming API."}, {"name": "metadata.object.special.details", "transport": "POST /rpc", "description": "Backward-compatible kind-specific SQL details for Configuration, Constant, CommonAttribute, SessionParameter, FunctionalOption, FunctionalOptionsParameter, DocumentNumerator, IntegrationService, CommandGroup, Role, ScheduledJob, EventSubscription, WebService, HTTPService, and DocumentJournal. Role details include object rights, RLS conditions, and restriction templates from its SQL .0 payload. Prefer metadata.object.properties. For DocumentJournal pass include_column_types=true to resolve column types."}, {"name": "metadata.form.decode", "transport": "POST /rpc", "description": "Decode one form into events, elements, commands, attributes, module summary, and decoded form parameters. evidence_mode controls undecoded payload evidence: none, summary, full, raw. Raw offsets require include_storage=true."}, {"name": "metadata.form.owner_index.build", "transport": "POST /rpc", "description": "Build/refresh the SQL-backed form owner index for CommonForm and object-owned forms from extension routes or direct form SQL payloads. XML remains analysis/learning only."}, {"name": "metadata.form.write_target.resolve", "transport": "POST /rpc", "description": "Resolve an agent-facing saved-state form write target into table, file_name, profile section, path, current value, candidates, and writable properties."}, {"name": "metadata.form.write_target.verify", "transport": "POST /rpc", "description": "Read-only agent check for a form write target. Verifies whether a saved-state form target is currently writable or whether the adapter would need to prepare ConfigSave/ConfigCASSave first."}, - {"name": "metadata.saved_state.prepare", "transport": "POST /rpc", "description": "Prepare an empty saved-state working copy by copying active object payload rows from Config->ConfigSave or ConfigCAS->ConfigCASSave. Default mode is plan; SQL insert requires allow_sql_saved_state_prepare=true and blocks on target collisions."}, - {"name": "metadata.saved_state.status", "transport": "POST /rpc", "description": "Read-only overview of ConfigSave/ConfigCASSave rows compared with their active SQL source. Reports changed, unchanged, saved_only, row/file counts, and sample files."}, - {"name": "metadata.saved_state.diff", "transport": "POST /rpc", "description": "Read-only comparison of a saved-state payload with its active SQL source. Accepts table/file_name or module_ref and reports changed/unchanged, needs_prepare, hashes, and compact payload diff."}, - {"name": "metadata.saved_state.changes.list", "transport": "POST /rpc", "description": "Read-only list of changed or saved-only ConfigSave/ConfigCASSave files with diff selectors and live SQL freshness."}, - {"name": "metadata.saved_state.forms.search", "transport": "POST /rpc", "description": "Fast saved-state form index/search over ConfigSave/ConfigCASSave form payloads by form, element, command, or text."}, - {"name": "metadata.saved_state.modules.search", "transport": "POST /rpc", "description": "Fast saved-state BSL module search over ConfigSave/ConfigCASSave. Accepts owner_guid/prefix or public object_type/object_name/object_guid selectors; name selectors are resolved to owner_guid when possible. Returns module_ref stream handles and streams[].write_plan_target for module writes."}, + {"name": "metadata.saved_state.prepare", "transport": "POST /rpc", "description": "Prepare an empty saved-state working copy by public 1C object name/ref and semantic layer. Default mode is plan; SQL insert requires allow_sql_saved_state_prepare=true and blocks on target collisions. include_storage=true exposes storage diagnostics."}, + {"name": "metadata.saved_state.status", "transport": "POST /rpc", "description": "Read-only name-first overview of a base_saved_state or extension_saved_state layer. Public mode reports semantic aggregate state; include_storage=true exposes SQL rows/files, hashes, and diff selectors."}, + {"name": "metadata.saved_state.diff", "transport": "POST /rpc", "description": "Agent-facing read-only comparison of a saved-state module with its active source. Prefer a 1C object ref or kind/name plus module_ordinal; generated module_ref and table/file_name remain accepted for follow-up tooling. Reports changed/unchanged, needs_prepare, hashes, and compact payload diff."}, + {"name": "metadata.saved_state.changes.list", "transport": "POST /rpc", "description": "Read-only name-first list of pending saved-state changes. Filter by semantic layer; public rows resolve 1C object/form/module context and hide SQL coordinates; include_storage=true exposes per-file diff/write diagnostics."}, + {"name": "metadata.saved_state.forms.search", "transport": "POST /rpc", "description": "Fast name-first saved-state form search by public owner ref, form, element, command, attribute, or text. Public rows expose 1C names and semantic selectors; include_storage=true opts into SQL files, GUIDs, brace paths, markers, and write diagnostics."}, + {"name": "metadata.saved_state.modules.search", "transport": "POST /rpc", "description": "Fast name-first saved-state BSL module search over ConfigSave/ConfigCASSave. Accepts public ref or kind/name selectors and resolves SQL routes internally. Public results expose 1C owner/form/module names and previews; include_storage=true opts into module_ref handles and low-level write targets."}, {"name": "metadata.form.write_matrix.build", "transport": "POST /rpc", "description": "Build a source-aware matrix of decoded saved-state form scalar properties and safe write-smoke candidates."}, {"name": "metadata.form.write_matrix.smoke", "transport": "POST /rpc", "description": "Run apply_and_rollback smoke writes for safe entries from metadata.form.write_matrix.build and report verified write routes."}, {"name": "metadata.form.element.write", "transport": "POST /rpc", "description": "Saved-state form element write planner. Resolves a decoded form element and builds a reviewable changes.propose payload for ConfigSave/ConfigCASSave. Requires allow_saved_state_write=true and does not write SQL."}, @@ -619,7 +773,7 @@ METHODS = [ {"name": "metadata.write.plan", "transport": "POST /rpc", "description": "Read-only metadata write planner. Resolves full 1C paths or concrete saved-state references, reports layer/provenance requirements, and never applies changes."}, {"name": "metadata.write.preflight", "transport": "POST /rpc", "description": "Read-only preflight for high-level writes. Combines metadata.write.plan with live saved-state verification and reports ready, needs_prepare, needs_resolution, or blocked before any write."}, {"name": "metadata.write.capabilities", "transport": "POST /rpc", "description": "Agent-facing matrix of what the adapter can read, plan, and write to the saved-state layer. SQL/storage details are hidden unless include_storage=true."}, - {"name": "metadata.write", "transport": "POST /rpc", "description": "High-level metadata write orchestrator. Routes saved-state form and module targets, builds reviewable proposals, and can apply with explicit saved-state SQL gates. Module writes support text, old/new, and routine_name/routine_text edits with expected_sha1/expected_text_sha1 guards."}, + {"name": "metadata.write", "transport": "POST /rpc", "description": "High-level metadata write orchestrator. Routes saved-state form, module, and scheduled-job schedule targets by public 1C names, builds reviewable proposals, and can apply with explicit saved-state SQL gates. Module writes support text, old/new, and routine_name/routine_text edits with expected_sha1/expected_text_sha1 guards."}, {"name": "metadata.write.history", "transport": "POST /rpc", "description": "List recent adapter write operations or fetch one operation_id, including status, routed method, target summary, backup ids, and full result for a specific operation."}, {"name": "metadata.write.rollback", "transport": "POST /rpc", "description": "Rollback a saved-state write by operation_id or backup_id using write history evidence. Requires allow_sql_saved_state_rollback=true."}, {"name": "code.write", "transport": "POST /rpc", "description": "Agent-facing BSL code write facade. Works with 1C names and code text, defaults to saving into the working saved-state layer, and hides SQL/storage details unless include_storage=true. Supports full module text, routine_name/routine_text, and unique old/new fragment replacement."}, @@ -644,9 +798,9 @@ METHODS = [ {"name": "access.graph.build", "transport": "POST /rpc or POST /access/graph", "description": "Build a normalized 1C access graph from an access snapshot: users, access groups, profiles, roles, data restrictions, and effective permissions with source chains."}, {"name": "access.user.explain", "transport": "POST /rpc or POST /access/user/explain", "description": "Explain BSP access for one BSP catalog user, including profile/group/role source chains and optional object/action filtering. For an ordinary request about users, start with infobase.users.search; BSP results do not replace Configurator role assignments."}, {"name": "access.users.search", "transport": "POST /rpc or POST /access/users/search", "description": "Search BSP catalog users by name, login, id/ref tail, or fuzzy fragment and return candidates for access.user.explain. Use only when the caller explicitly asks about BSP users, access groups, profiles, or RLS; ordinary 'users' means infobase/Configurator users."}, - {"name": "access.keys.query", "transport": "POST /rpc or POST /access/keys/query", "description": "Page through BSP access key registers by group, user_set, object, access_set, or all. This keeps large RLS key data out of the main snapshot unless requested."}, - {"name": "access.object_keys.resolve", "transport": "POST /rpc or POST /access/object-keys/resolve", "description": "Page through BSP object access keys and resolve object_sql_number/object_id to readable data-record presentations where possible."}, - {"name": "access.object.explain", "transport": "POST /rpc or POST /access/object/explain", "description": "Explain who can see a BSP-protected data object by resolving object access keys back to access groups, user sets, and users where possible."}, + {"name": "access.keys.query", "transport": "POST /rpc or POST /access/keys/query", "description": "Page through BSP access key registers by group, user_set, object, access_set, or all. In object mode pass object_ref for a metadata object name and record_ref for a concrete application-data record; kind remains the query area. Legacy raw BSP object/object_id filters remain supported."}, + {"name": "access.object_keys.resolve", "transport": "POST /rpc or POST /access/object-keys/resolve", "description": "Page through BSP object access keys. Pass object_ref for a public 1C metadata name and record_ref for a concrete application-data record; the adapter resolves internal object_sql_number/object_id values and readable presentations."}, + {"name": "access.object.explain", "transport": "POST /rpc or POST /access/object/explain", "description": "Explain who can see a BSP-protected data record. Pass object_ref for its metadata object and record_ref for the concrete record; legacy raw BSP object/object_id/access_key filters remain supported."}, {"name": "access.object.roles", "transport": "POST /rpc or POST /access/object/roles", "description": "Find BSP roles that grant permissions for one metadata object and summarize read/insert/update/delete rights."}, {"name": "access.object.subjects", "transport": "POST /rpc or POST /access/object/subjects", "description": "Find roles, profiles, access groups, and users that receive permissions for one metadata object."}, {"name": "access.rls.discover", "transport": "POST /rpc or POST /access/rls/discover", "description": "Discover metadata candidates for BSP/RLS/data restriction extraction by names such as Огранич, Доступ, RLS, and Ключ."}, @@ -662,7 +816,7 @@ METHODS = [ {"name": "semantic.cache.rebuild", "transport": "POST /rpc", "description": "Warm semantic/artifact caches from fresh extension route cache entries. Currently supports Template/MOXCEL routes and queues refreshed documents for embeddings."}, {"name": "semantic.cache.pending", "transport": "POST /rpc", "description": "List semantic cache documents that need embeddings. Returns text previews and content_sha1 preconditions for safe external embedding workers."}, {"name": "semantic.cache.embedding.upsert", "transport": "POST /rpc", "description": "Store an embedding for one semantic cache document only when document_id and content_sha1 still match the current source-derived document."}, - {"name": "metadata.module_owner_cache.prune", "transport": "POST /rpc", "description": "Targeted cleanup of module-owner cache entries by owner_guid and/or module_ref. Supports dry_run JSON boolean."}, + {"name": "metadata.module_owner_cache.prune", "transport": "POST /rpc", "description": "Targeted local module-owner cache cleanup. Prefer a 1C owner ref or kind/name; owner GUID is resolved internally. Generated module_ref selectors remain accepted. Supports dry_run JSON boolean and never writes platform SQL."}, {"name": "extensions.list", "transport": "GET /extensions or POST /rpc", "description": "Публичный список расширений конфигурации: имя, порядок, дата обновления, активность и GUID. require base_id, limit >= 1 and offset >= 0 when provided. Технические поля скрыты, если явно не передан include_storage=true."}, {"name": "extension.cache.status", "transport": "POST /rpc", "description": "Report extension route cache freshness grouped by extension/kind, including stale counts and oldest validation timestamps."}, {"name": "extension.cache.rebuild", "transport": "POST /rpc", "description": "Warm the validated extension route cache from live extension manifests and descriptor payloads. Use extension/kind/max_items to scope the rebuild; no vector or semantic result is treated as authoritative."}, @@ -681,8 +835,8 @@ METHODS = [ {"name": "storage.saved_state.rollback", "transport": "POST /rpc", "description": "Rollback a saved-state apply by backup_id or backup_path. Requires allow_sql_saved_state_rollback=true."}, {"name": "storage.saved_state.backups.list", "transport": "POST /rpc", "description": "List local saved-state apply backups with optional base_id/table/file_name filters."}, {"name": "metadata.dbnames.summary", "transport": "POST /rpc", "description": "Low-level diagnostic DBNames summary for developers. Requires diagnostic=true."}, - {"name": "code.search", "transport": "POST /rpc", "description": "Search in decoded BSL by query with owner/module filters and pagination. Defaults to state=working and passes saved-state preference to modules.search; use state=active/save/both when comparing activated and saved code. Object scope accepts ref, kind/name/guid, or object_type/object_name/object_guid. Agent-facing wrapper over modules.search with stable item payload: line/column/context/resolved_owner/origin/module/read_selector. read_selector.method is code.read and can be passed directly to the next read call. Counts include complete/scan_limit_hit and owner resolution counters."}, - {"name": "code.read", "transport": "POST /rpc", "description": "Read module or routine text by owner selector (ref, kind/name/guid, object_type/object_name/object_guid) or module_ref from code.search read_selector with optional line/column extraction and resolved owner."}, + {"name": "code.search", "transport": "POST /rpc", "description": "Search decoded BSL in configuration_view=effective_working by default: the logical Designer snapshot, with saved development changes and extension layers preferred. Use runtime_applied only for code executable now, or compare for both. Object scope accepts ref, kind/name/guid, or object_type/object_name/object_guid. Every item has read_selector.method is code.read; reuse that selector directly. Results expose logical owners and read selectors, not Config/ConfigSave internals."}, + {"name": "code.read", "transport": "POST /rpc", "description": "Read module or routine text in configuration_view=effective_working by default, by logical owner selector or a prior code.search read_selector. Pass module_ref from code.search read_selector unchanged when it is present. runtime_applied reads code executable now; compare returns both. The response identifies the view so saved Designer changes are never mistaken for already activated runtime code."}, {"name": "code.symbol.resolve", "transport": "POST /rpc", "description": "Conservatively resolve a BSL expression inside a concrete module/routine context. Full 1C paths and context-proven members are metadata; routine parameters, local variables, and short object names remain code symbols."}, {"name": "templates.bindings", "transport": "POST /rpc", "description": "Extract template dependencies, parameters/fields bindings and owner chain for a report/processing/form object."}, {"name": "diagnostics.call_chain", "transport": "POST /rpc", "description": "Build diagnostic call chain for a code entrypoint: entry method owner/module, possible overrides, and detected static usage links in the same object scope."}, @@ -704,6 +858,587 @@ METHODS = [ {"name": "changes.propose", "transport": "POST /changes/propose or POST /rpc", "description": "Builds a reviewable encoded payload proposal from live source + path edits. Does not write to SQL."}, ] +DATA_OBJECT_SELECTOR_METHODS = frozenset( + { + "data.schema", + "data.list", + "data.get", + "data.count", + "data.query", + "data.present", + "data.movements", + "data.virtual", + } +) + +METHOD_INPUT_SCHEMAS = { + "repository.lock.confirm": { + "type": "object", + "required": ["base_id", "request_id", "user_confirmed_locked"], + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "request_id": {"type": "string", "description": "Exact id returned by repository.lock.request."}, + "user_confirmed_locked": {"type": "boolean", "const": True, "description": "Explicit statement that the user has captured the exact request scope in Configurator."}, + "confirmed_repository_user": {"type": "string", "description": "Optional only when repository_user is not saved in the resolved layer setting; otherwise, if supplied, it must match."}, + }, + "example": {"base_id": "neft", "request_id": "rreq-…", "user_confirmed_locked": True}, + "notes": [ + "Do not repeat objects, operation, or layer_id: they are taken from the persisted request.", + "Use the next_call object returned by repository.lock.request whenever available.", + "The successful response returns write_context with lock_session_id; copy it into write preflight and write operations.", + ], + }, +} +METHOD_INPUT_SCHEMAS.update( + { + method: { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "object_ref": { + "type": "string", + "description": "Preferred public 1C object ref, for example Справочники.Номенклатура or РегистрСведений.КурсыВалют.", + }, + "ref": { + "type": "string", + "description": "Public object ref; data.get/data.present also accept a legacy 32-character record ref when object_ref identifies the object.", + }, + "kind": {"type": "string", "description": "1C metadata kind in English or Russian."}, + "name": {"type": "string", "description": "Exact 1C metadata object name."}, + "guid": {"type": "string", "description": "Metadata object GUID; callers normally should prefer a public name."}, + "object_type": {"type": "string", "description": "MCP alias of kind."}, + "object_name": {"type": "string", "description": "MCP alias of name."}, + "object_guid": {"type": "string", "description": "MCP alias of guid."}, + "record_ref": {"type": "string", "description": "32-character 1C application-data reference id."}, + "recorder_ref": {"type": "string", "description": "32-character recorder reference for data.movements."}, + }, + } + for method in DATA_OBJECT_SELECTOR_METHODS + } +) +METHOD_INPUT_SCHEMAS["metadata.support.decode"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "ref": { + "type": "string", + "description": "Optional public 1C object ref whose supplier support rule should be returned.", + }, + "kind": {"type": "string", "description": "Optional metadata kind in English or Russian."}, + "name": {"type": "string", "description": "Optional exact 1C object name."}, + "guid": {"type": "string", "description": "Optional metadata object GUID."}, + "object_type": {"type": "string", "description": "MCP alias of kind."}, + "object_name": {"type": "string", "description": "MCP alias of name."}, + "object_guid": {"type": "string", "description": "MCP alias of guid."}, + "member_ref": { + "type": "string", + "description": "Optional full public child ref, for example Catalog.Номенклатура.Attribute.Артикул.", + }, + "child_ref": {"type": "string", "description": "Alias of member_ref."}, + "member_kind": { + "type": "string", + "description": "Optional child category: Attribute, TabularSection, Dimension, or Resource; Russian aliases are accepted.", + }, + "member_name": {"type": "string", "description": "Optional exact child metadata name."}, + "canonical_path": {"type": "string", "description": "Alias of a full public object/member path."}, + "layer_id": { + "type": "string", + "description": "base or extension:; defaults to base.", + }, + }, +} +METHOD_INPUT_SCHEMAS["metadata.object.property.write"] = { + "type": "object", + "required": ["base_id", "property", "value"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "ref": {"type": "string", "description": "Preferred public 1C object ref."}, + "kind": {"type": "string", "description": "Optional metadata kind."}, + "name": {"type": "string", "description": "Optional exact 1C object name."}, + "guid": {"type": "string", "description": "Optional metadata GUID; names are preferred."}, + "object_type": {"type": "string", "description": "MCP alias of kind."}, + "object_name": {"type": "string", "description": "MCP alias of name."}, + "object_guid": {"type": "string", "description": "MCP alias of guid."}, + "extension": {"type": "string", "description": "Optional extension name or GUID."}, + "layer": { + "type": "string", + "enum": ["base_saved_state", "extension_saved_state"], + "description": "Semantic saved-state layer; inferred from extension when omitted.", + }, + "property": { + "type": "string", + "enum": ["synonym", "comment"], + "description": "Safe scalar identity property. Object name/rename is intentionally unsupported.", + }, + "value": {"type": "string", "description": "Requested scalar value; an empty string is allowed."}, + "language": {"type": "string", "description": "Existing synonym locale to edit; defaults to ru."}, + "expected_old": {"type": "string", "description": "Optional semantic compare-and-set guard."}, + "expected_sha1": {"type": "string", "description": "Optional saved-state payload compare-and-set guard."}, + "execution_mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"]}, + "allow_saved_state_write": {"type": "boolean"}, + "auto_prepare_saved_state": {"type": "boolean"}, + "allow_sql_saved_state_prepare": {"type": "boolean"}, + "allow_sql_saved_state_apply": {"type": "boolean"}, + "allow_sql_saved_state_rollback": {"type": "boolean"}, + "include_storage": {"type": "boolean"}, + }, +} +METHOD_INPUT_SCHEMAS["metadata.object.member.add"] = { + "type": "object", + "required": ["base_id", "template_member_ref", "new_member_name"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string"}, + "ref": {"type": "string", "description": "Optional public parent object ref; inferred from template_member_ref."}, + "kind": {"type": "string"}, + "name": {"type": "string"}, + "object_type": {"type": "string"}, + "object_name": {"type": "string"}, + "template_member_ref": { + "type": "string", + "description": "Full public ref of an existing Attribute to clone.", + }, + "new_member_name": {"type": "string", "description": "New 1C Attribute name."}, + "new_member_synonym": {"type": "string", "description": "Optional ru synonym; defaults to the new name."}, + "new_member_comment": {"type": "string", "description": "Optional comment; otherwise inherited from the template."}, + "extension": {"type": "string"}, + "layer": {"type": "string", "enum": ["base_saved_state", "extension_saved_state"]}, + "expected_sha1": {"type": "string"}, + "execution_mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"]}, + "allow_saved_state_write": {"type": "boolean"}, + "auto_prepare_saved_state": {"type": "boolean"}, + "allow_sql_saved_state_prepare": {"type": "boolean"}, + "allow_sql_saved_state_apply": {"type": "boolean"}, + "allow_sql_saved_state_rollback": {"type": "boolean"}, + "include_storage": {"type": "boolean"}, + }, +} +METHOD_INPUT_SCHEMAS["metadata.code_index.build"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "ref": { + "type": "string", + "description": "Optional public 1C object ref. Omit the object selector only for an intentional bounded global scan.", + }, + "kind": {"type": "string", "description": "Optional metadata kind in English or Russian."}, + "name": {"type": "string", "description": "Optional exact 1C object name."}, + "guid": {"type": "string", "description": "Optional metadata object GUID."}, + "object_type": {"type": "string", "description": "MCP alias of kind."}, + "object_name": {"type": "string", "description": "MCP alias of name."}, + "object_guid": {"type": "string", "description": "MCP alias of guid."}, + "extension_guid": {"type": "string", "description": "Optional extension layer GUID."}, + "table": {"type": "string", "description": "Optional explicit storage state; normally resolved automatically."}, + "max_items": {"type": "integer", "minimum": 1, "maximum": 20000}, + "scan_limit": {"type": "integer", "minimum": 1, "maximum": 50000}, + "include_vectors": {"type": "boolean"}, + }, +} +METHOD_INPUT_SCHEMAS["metadata.form.owner_index.build"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "ref": { + "type": "string", + "description": "Optional public 1C owner ref, for example Обработки.ОбменДанными or ОбщиеФормы.Параметры.", + }, + "kind": {"type": "string", "description": "Optional owner metadata kind in English or Russian."}, + "name": {"type": "string", "description": "Optional exact owner object name."}, + "guid": {"type": "string", "description": "Optional owner metadata GUID."}, + "object_type": {"type": "string", "description": "MCP alias of owner kind."}, + "object_name": {"type": "string", "description": "MCP alias of owner name."}, + "object_guid": {"type": "string", "description": "MCP alias of owner GUID."}, + "form": {"type": "string", "description": "Optional exact form name inside the selected owner."}, + "form_name": {"type": "string", "description": "Alias of form."}, + "extension": {"type": "string", "description": "Optional extension name or GUID."}, + "table": {"type": "string", "description": "Optional direct diagnostic storage route table."}, + "file_name": {"type": "string", "description": "Optional direct diagnostic storage file."}, + }, +} +METHOD_INPUT_SCHEMAS["metadata.form.command_button.verify"] = { + "type": "object", + "required": ["base_id", "command_name"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "ref": {"type": "string", "description": "Optional public 1C form-owner ref."}, + "kind": {"type": "string", "description": "Optional owner metadata kind."}, + "name": {"type": "string", "description": "Optional exact owner object name."}, + "guid": {"type": "string", "description": "Optional owner metadata GUID."}, + "object_type": {"type": "string", "description": "MCP alias of owner kind."}, + "object_name": {"type": "string", "description": "MCP alias of owner name."}, + "object_guid": {"type": "string", "description": "MCP alias of owner GUID."}, + "form": {"type": "string", "description": "Exact nested form name."}, + "form_name": {"type": "string", "description": "Alias of form."}, + "command_name": {"type": "string", "description": "Exact form command name."}, + "button_name": {"type": "string", "description": "Expected button name; defaults to command_name."}, + "handler_name": {"type": "string", "description": "Expected handler routine; defaults to command_name."}, + "extension": {"type": "string", "description": "Optional extension name or GUID."}, + "include_storage": {"type": "boolean"}, + }, +} +METHOD_INPUT_SCHEMAS["metadata.form.command_button.write"] = { + "type": "object", + "required": ["base_id", "command_name"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "ref": {"type": "string", "description": "Optional public 1C form-owner ref."}, + "kind": {"type": "string", "description": "Optional owner metadata kind."}, + "name": {"type": "string", "description": "Optional exact owner object name."}, + "guid": {"type": "string", "description": "Optional owner metadata GUID."}, + "object_type": {"type": "string", "description": "MCP alias of owner kind."}, + "object_name": {"type": "string", "description": "MCP alias of owner name."}, + "object_guid": {"type": "string", "description": "MCP alias of owner GUID."}, + "form": {"type": "string", "description": "Exact nested form name."}, + "form_name": {"type": "string", "description": "Alias of form."}, + "form_guid": {"type": "string", "description": "Optional form GUID, distinct from the owner GUID."}, + "command_name": {"type": "string", "description": "Exact command name to upsert."}, + "button_name": {"type": "string", "description": "Button name; defaults to command_name."}, + "handler_name": {"type": "string", "description": "Handler routine name; defaults to command_name."}, + "extension": {"type": "string", "description": "Optional extension name or GUID."}, + "execution_mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"]}, + "allow_saved_state_write": {"type": "boolean"}, + "allow_sql_saved_state_apply": {"type": "boolean"}, + "allow_sql_saved_state_rollback": {"type": "boolean"}, + }, +} +_FORM_WRITE_TARGET_SELECTOR_PROPERTIES = { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "ref": {"type": "string", "description": "Optional public 1C form-owner ref."}, + "kind": {"type": "string", "description": "Optional owner metadata kind."}, + "name": {"type": "string", "description": "Optional exact owner object name."}, + "guid": {"type": "string", "description": "Optional owner metadata GUID."}, + "object_type": {"type": "string", "description": "MCP alias of owner kind."}, + "object_name": {"type": "string", "description": "MCP alias of owner name."}, + "object_guid": {"type": "string", "description": "MCP alias of owner GUID."}, + "form": {"type": "string", "description": "Exact nested form name."}, + "form_name": {"type": "string", "description": "Alias of form."}, + "form_guid": {"type": "string", "description": "Optional form GUID, distinct from the owner GUID."}, + "element": {"type": "string", "description": "Exact form element name."}, + "element_name": {"type": "string", "description": "Alias of element."}, + "command": {"type": "string", "description": "Exact form command name."}, + "attribute": {"type": "string", "description": "Exact form attribute name."}, + "property": {"type": "string", "description": "Scalar property to resolve."}, + "extension": {"type": "string", "description": "Optional extension name or GUID."}, + "table": {"type": "string", "description": "Saved-state table."}, + "file_name": {"type": "string", "description": "Optional concrete saved-state file."}, + "include_storage": {"type": "boolean"}, +} +for _form_write_target_method in ("metadata.form.write_target.resolve", "metadata.form.write_target.verify"): + METHOD_INPUT_SCHEMAS[_form_write_target_method] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": dict(_FORM_WRITE_TARGET_SELECTOR_PROPERTIES), + } +for _form_element_write_method in ("metadata.form.element.write", "metadata.form.element.write_apply"): + METHOD_INPUT_SCHEMAS[_form_element_write_method] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + **_FORM_WRITE_TARGET_SELECTOR_PROPERTIES, + "edits": {"type": "array", "description": "Non-empty scalar property edit list."}, + "execution_mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"]}, + "allow_saved_state_write": {"type": "boolean"}, + "allow_sql_saved_state_apply": {"type": "boolean"}, + "allow_sql_saved_state_rollback": {"type": "boolean"}, + }, + } +METHOD_INPUT_SCHEMAS["metadata.form.target.move"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + **_FORM_WRITE_TARGET_SELECTOR_PROPERTIES, + "from_element": {"type": "string", "description": "Exact source sibling element name."}, + "to_element": {"type": "string", "description": "Exact destination sibling element name."}, + "with_element": {"type": "string", "description": "Alias of to_element for a sibling swap."}, + "after_element": {"type": "string", "description": "Existing sibling used as the destination slot."}, + "execution_mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"]}, + "allow_saved_state_write": {"type": "boolean"}, + "allow_sql_saved_state_apply": {"type": "boolean"}, + "allow_sql_saved_state_rollback": {"type": "boolean"}, + }, +} +for _form_write_matrix_method in ("metadata.form.write_matrix.build", "metadata.form.write_matrix.smoke"): + METHOD_INPUT_SCHEMAS[_form_write_matrix_method] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + **_FORM_WRITE_TARGET_SELECTOR_PROPERTIES, + "max_candidates": {"type": "integer", "minimum": 1, "maximum": 5000}, + "allow_sql_saved_state_apply": {"type": "boolean"}, + "allow_sql_saved_state_rollback": {"type": "boolean"}, + }, + } +for _write_learning_capture_method in ("metadata.write_learning.capture_before", "metadata.write_learning.capture_after"): + METHOD_INPUT_SCHEMAS[_write_learning_capture_method] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + **_FORM_WRITE_TARGET_SELECTOR_PROPERTIES, + "learning_id": {"type": "string", "description": "Local write-learning case identifier."}, + }, + } +METHOD_INPUT_SCHEMAS["metadata.saved_state.forms.search"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + **_FORM_WRITE_TARGET_SELECTOR_PROPERTIES, + "query": {"type": "string", "description": "Optional text search inside the decoded form."}, + "limit": {"type": "integer", "minimum": 1, "maximum": 1000}, + "scan_limit": {"type": "integer", "minimum": 1, "maximum": 5000}, + "tables": {"type": "array", "items": {"type": "string"}}, + }, +} +METHOD_INPUT_SCHEMAS["metadata.saved_state.status"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "layer": { + "type": "string", + "enum": ["base_saved_state", "extension_saved_state"], + "description": "Preferred semantic saved-state layer selector.", + }, + "table": { + "type": "string", + "enum": ["ConfigSave", "ConfigCASSave"], + "description": "Optional low-level storage selector; prefer layer.", + }, + "prefix": {"type": "string", "description": "Optional diagnostic storage file prefix."}, + "limit": {"type": "integer", "minimum": 1, "maximum": 5000}, + "include_files": {"type": "boolean"}, + "include_unchanged": {"type": "boolean"}, + "include_storage": { + "type": "boolean", + "description": "Expose SQL database/table/file coordinates, hashes, and concrete diff selectors.", + }, + }, +} +METHOD_INPUT_SCHEMAS["metadata.saved_state.prepare"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "layer": { + "type": "string", + "enum": ["base_saved_state", "extension_saved_state"], + "description": "Preferred semantic target saved-state layer.", + }, + "ref": {"type": "string", "description": "Preferred public 1C object reference."}, + "kind": {"type": "string", "description": "Optional metadata kind."}, + "name": {"type": "string", "description": "Optional exact 1C object name."}, + "object_type": {"type": "string", "description": "MCP alias of kind."}, + "object_name": {"type": "string", "description": "MCP alias of name."}, + "extension": {"type": "string", "description": "Optional extension name or GUID."}, + "target_table": { + "type": "string", + "enum": ["ConfigSave", "ConfigCASSave"], + "description": "Optional low-level storage selector; prefer layer.", + }, + "mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify"]}, + "allow_sql_saved_state_prepare": {"type": "boolean"}, + "include_storage": { + "type": "boolean", + "description": "Expose SQL tables, database, files, hashes, row details, and low-level apply diagnostics.", + }, + }, +} +METHOD_INPUT_SCHEMAS["metadata.saved_state.changes.list"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "layer": { + "type": "string", + "enum": ["base_saved_state", "extension_saved_state"], + "description": "Preferred semantic saved-state layer filter.", + }, + "table": { + "type": "string", + "enum": ["ConfigSave", "ConfigCASSave"], + "description": "Optional low-level saved-state layer filter.", + }, + "include_unchanged": {"type": "boolean"}, + "include_context": { + "type": "boolean", + "description": "Request resolved object/form/module context explicitly; public mode enables it automatically.", + }, + "group_by_context": {"type": "boolean"}, + "context_limit": {"type": "integer", "minimum": 0, "maximum": 500}, + "limit": {"type": "integer", "minimum": 1, "maximum": 5000}, + "include_storage": { + "type": "boolean", + "description": "Expose SQL tables/files, hashes, concrete diff selectors, module refs, and low-level actions.", + }, + }, +} +METHOD_INPUT_SCHEMAS["metadata.saved_state.modules.search"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "ref": {"type": "string", "description": "Optional public 1C module-owner ref."}, + "kind": {"type": "string", "description": "Optional owner metadata kind."}, + "name": {"type": "string", "description": "Optional exact owner object name."}, + "guid": {"type": "string", "description": "Optional owner metadata GUID."}, + "object_type": {"type": "string", "description": "MCP alias of owner kind."}, + "object_name": {"type": "string", "description": "MCP alias of owner name."}, + "object_guid": {"type": "string", "description": "MCP alias of owner GUID."}, + "owner_guid": {"type": "string", "description": "Low-level exact owner GUID alias."}, + "extension": {"type": "string", "description": "Optional extension name or GUID."}, + "layer": { + "type": "string", + "enum": ["base_saved_state", "extension_saved_state"], + "description": "Optional semantic saved-state layer; prefer this to SQL table names.", + }, + "query": {"type": "string", "description": "Optional BSL text query."}, + "file_name": {"type": "string", "description": "Optional concrete saved-state file."}, + "stream_index": {"type": "integer", "minimum": 0}, + "limit": {"type": "integer", "minimum": 1, "maximum": 1000}, + "scan_limit": {"type": "integer", "minimum": 1, "maximum": 5000}, + "tables": {"type": "array", "items": {"type": "string"}}, + "include_storage": { + "type": "boolean", + "description": "Expose SQL tables, file names, GUID identities, module_ref handles, hashes, and low-level write targets.", + }, + }, +} +METHOD_INPUT_SCHEMAS["code.write"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "ref": {"type": "string", "description": "Preferred public 1C module-owner ref."}, + "kind": {"type": "string", "description": "Owner metadata kind."}, + "name": {"type": "string", "description": "Exact owner object name."}, + "guid": {"type": "string", "description": "Optional owner metadata GUID."}, + "object_type": {"type": "string", "description": "MCP alias of owner kind."}, + "object_name": {"type": "string", "description": "MCP alias of owner name."}, + "object_guid": {"type": "string", "description": "MCP alias of owner GUID."}, + "form": {"type": "string", "description": "Optional nested form name."}, + "form_name": {"type": "string", "description": "Alias of form."}, + "routine_name": {"type": "string", "description": "Exact BSL routine name."}, + "routine_text": {"type": "string", "description": "Replacement BSL routine text."}, + "module_text": {"type": "string", "description": "Replacement full module text."}, + "old": {"type": "string", "description": "Exact old fragment."}, + "new": {"type": "string", "description": "Replacement fragment."}, + "extension": {"type": "string", "description": "Optional extension name or GUID."}, + "execution_mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"]}, + "include_storage": {"type": "boolean"}, + }, +} +METHOD_INPUT_SCHEMAS["templates.areas.find"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "ref": {"type": "string", "description": "Preferred public 1C template ref, for example Макеты.ПечатнаяФорма."}, + "kind": {"type": "string", "description": "Template metadata kind."}, + "name": {"type": "string", "description": "Exact template object name."}, + "guid": {"type": "string", "description": "Optional template GUID."}, + "object_type": {"type": "string", "description": "MCP alias of kind."}, + "object_name": {"type": "string", "description": "MCP alias of template name."}, + "object_guid": {"type": "string", "description": "MCP alias of template GUID."}, + "extension": {"type": "string", "description": "Optional extension name or GUID."}, + "area_name": {"type": "string", "description": "Exact named area."}, + "area_query": {"type": "string", "description": "Named-area substring query."}, + "area_occurrence": {"type": "integer", "minimum": 1}, + "route_ref": {"type": "string", "description": "Optional low-level : route."}, + "include_coverage": {"type": "boolean"}, + }, +} +METHOD_INPUT_SCHEMAS["metadata.cache.lookup"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "ref": {"type": "string", "description": "Preferred public 1C object ref."}, + "kind": {"type": "string", "description": "Metadata kind."}, + "name": {"type": "string", "description": "Exact object name."}, + "guid": {"type": "string", "description": "Optional metadata GUID."}, + "object_type": {"type": "string", "description": "MCP alias of kind."}, + "object_name": {"type": "string", "description": "MCP alias of name."}, + "object_guid": {"type": "string", "description": "MCP alias of GUID."}, + }, +} +_ACCESS_OBJECT_FILTER_PROPERTIES = { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "object_ref": { + "type": "string", + "description": "Public 1C metadata object ref, for example Справочники.Номенклатура. Resolved internally to the BSP object_sql_number.", + }, + "record_ref": { + "type": "string", + "description": "Concrete 1C application-data record reference (32 hex characters or UUID form). Resolved internally to the BSP object_id.", + }, + "object": { + "type": "string", + "description": "Legacy low-level exact BSP object field. Prefer object_ref plus record_ref.", + }, + "object_id": { + "type": "string", + "description": "Legacy low-level exact BSP record id. Prefer record_ref.", + }, + "object_sql_number": { + "type": "integer", + "description": "Legacy physical DBNames number. Prefer object_ref.", + }, + "access_key": {"type": "string", "description": "Optional exact BSP access-key id."}, + "limit": {"type": "integer", "minimum": 1, "maximum": 20000}, + "offset": {"type": "integer", "minimum": 0, "maximum": 10000000}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120}, + "max_resolved_records": {"type": "integer", "minimum": 0, "maximum": 5000}, +} +METHOD_INPUT_SCHEMAS["access.keys.query"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": { + **_ACCESS_OBJECT_FILTER_PROPERTIES, + "kind": { + "type": "string", + "enum": ["group", "user_set", "object", "set", "all"], + "description": "Access-key query area, not a 1C metadata kind. Use object_ref to select a metadata object.", + }, + "area": { + "type": "string", + "enum": ["group", "user_set", "object", "set", "all"], + "description": "Alias of kind.", + }, + "resolve_records": {"type": "boolean"}, + }, +} +for _access_object_method in ("access.object_keys.resolve", "access.object.explain"): + METHOD_INPUT_SCHEMAS[_access_object_method] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": True, + "properties": dict(_ACCESS_OBJECT_FILTER_PROPERTIES), + } + RELATED_SECTION_RULES = { "Document": [ {"path": "4", "category": "Template"}, @@ -781,8 +1516,8 @@ KIND_CAPABILITIES = { "Task": ["list", "get", "attributes", "addressing_attributes", "forms", "commands", "modules"], "Constant": ["list", "get", "value_type", "properties", "special_details"], "ChartOfCharacteristicTypes": ["list", "get", "attributes", "tabular_sections", "forms", "templates", "modules"], - "ChartOfAccounts": ["list", "get", "attributes", "tabular_sections", "accounting_flags", "forms", "templates", "modules"], - "ChartOfCalculationTypes": ["list", "get", "attributes", "tabular_sections", "forms", "templates", "modules", "properties", "special_details"], + "ChartOfAccounts": ["list", "get", "attributes", "tabular_sections", "accounting_flags", "forms", "modules"], + "ChartOfCalculationTypes": ["list", "get", "attributes", "tabular_sections", "forms", "modules", "properties", "special_details"], "ExchangePlan": ["list", "get", "attributes", "tabular_sections", "forms", "templates", "commands", "modules"], "DocumentJournal": ["list", "get", "columns", "forms", "templates", "commands", "properties", "special_details"], "DocumentNumerator": ["list", "get", "number_type", "number_length", "periodicity", "unique_check", "properties", "special_details"], @@ -794,7 +1529,7 @@ KIND_CAPABILITIES = { "Form": ["get", "form_details"], "Template": ["get", "template_details"], "Command": ["get"], - "CalculationRegister": ["list", "get", "dimensions", "resources", "attributes", "recalculations", "modules", "properties", "special_details"], + "CalculationRegister": ["list", "get", "dimensions", "resources", "attributes", "recalculations", "forms", "templates", "commands", "modules", "properties", "special_details"], "Sequence": ["list", "get", "dimensions"], "SelectionCriterion": ["list", "get", "type", "content", "forms", "properties", "special_details"], "EventSubscription": ["list", "get", "source", "handler", "properties", "special_details"], @@ -814,12 +1549,12 @@ KIND_CAPABILITIES = { "XDTOPackage": ["list", "get", "types", "properties", "special_details"], "ExternalDataSource": ["list", "get", "tables", "fields", "key_fields", "types", "cubes", "functions", "properties", "special_details"], "IntegrationService": ["list", "get", "channels", "modules", "properties", "special_details"], - "CommonTemplate": ["list", "get", "template_details", "template_read", "template_analyze", "template_map"], + "CommonTemplate": ["list", "get", "templates", "template_details", "template_read", "template_analyze", "template_map"], "CommonPicture": ["list", "get", "binary_preview", "properties", "special_details"], "CommandGroup": ["list", "get", "commands", "properties", "special_details"], "Style": ["list", "get", "style_items", "properties", "special_details"], "StyleItem": ["list", "get", "value_type", "properties", "special_details"], - "Interface": ["list", "get", "commands"], + "Interface": ["list", "get"], } CHILD_METADATA_KINDS = {"Form", "Template", "Command"} @@ -830,6 +1565,15 @@ def normalize(value: Any) -> str: return re.sub(r"[\s._-]+", "", str(value or "")).casefold() +for _kind, _kind_ru in RU_KIND.items(): + KIND_ALIASES[normalize(_kind)] = _kind + KIND_ALIASES[normalize(_kind_ru)] = _kind +for _code_root, _kind in ONEC_CODE_ROOT_KIND.items(): + KIND_ALIASES[normalize(_code_root)] = _kind +for _type_qualifier, _kind in ONEC_TYPE_QUALIFIER_KIND.items(): + KIND_ALIASES[normalize(_type_qualifier)] = _kind + + def normalize_exact(value: Any) -> str: return re.sub(r"\s+", " ", str(value or "").strip()).casefold() @@ -1255,6 +1999,62 @@ def canonical_kind(value: str | None) -> str | None: return KIND_ALIASES.get(normalize(stripped), stripped) +def known_canonical_kind(value: str | None) -> str | None: + """Resolve only known 1C metadata roots, including code and value-type roots.""" + + candidate = canonical_kind(value) + return candidate if candidate in RU_KIND else None + + +def parse_1c_object_path(value: Any, explicit_kind: str | None = None) -> dict[str, Any]: + """Parse public refs and code-like 1C paths without exposing storage identifiers.""" + + raw = str(value or "").strip() + parts = [part.strip() for part in raw.split(".") if part.strip()] + result: dict[str, Any] = { + "input": raw, + "parts": parts, + "kind": known_canonical_kind(explicit_kind), + "name": "", + "member_path": [], + "recognized_root": False, + } + if not parts: + return result + + first = parts[0] + if first.casefold().startswith("cfg:"): + first = first[4:].strip() + parts[0] = first + + root_index = 0 + if normalize(first) in {"metadata", "метаданные"}: + root_index = 1 + root = parts[root_index] if len(parts) > root_index else "" + root_kind = known_canonical_kind(root) + if root_kind: + result["recognized_root"] = True + result["input_root"] = root + result["kind"] = result["kind"] or root_kind + name_index = root_index + 1 + if len(parts) > name_index: + result["name"] = parts[name_index] + result["member_path"] = parts[name_index + 1 :] + elif result["kind"]: + result["name"] = parts[0] + result["member_path"] = parts[1:] + else: + result["name"] = raw + + kind = result.get("kind") + name = str(result.get("name") or "") + if kind and name: + result["canonical_ref"] = f"{kind}.{name}" + result["metadata_ref"] = f"{RU_KIND.get(kind, kind)}.{name}" + result["code_ref"] = f"{ONEC_CODE_ROOT_BY_KIND.get(kind, RU_KIND.get(kind, kind))}.{name}" + return result + + def parse_kind_request(kind: str | None) -> tuple[str | None, str | None]: raw = normalize(kind) public_values = set(PUBLIC_KIND.values()) @@ -1269,10 +2069,13 @@ def parse_kind_request(kind: str | None) -> tuple[str | None, str | None]: def parse_object_query(kind: str | None, name: str) -> tuple[str | None, str]: query = str(name or "").strip() + parsed = parse_1c_object_path(query, explicit_kind=kind) + if parsed.get("kind") and parsed.get("name"): + return str(parsed["kind"]), str(parsed["name"]) if "." not in query: return parse_kind_request(kind)[0], query left, right = query.split(".", 1) - left = left[4:] if left.startswith("cfg:") else left + left = left[4:] if left.casefold().startswith("cfg:") else left return parse_kind_request(kind)[0] or parse_kind_request(left)[0], right @@ -1339,6 +2142,53 @@ def normalize_object_selector_aliases(payload: dict[str, Any], method: str) -> d return result +def normalize_data_object_selector_aliases(payload: dict[str, Any], method: str) -> dict[str, Any]: + """Normalize data-method object names without consuming record references.""" + + object_ref = payload.get("object_ref") + if object_ref is not None and not isinstance(object_ref, str): + return invalid_argument(method, "object_ref", "object_ref must be a JSON string.") + ordinal_value = first_non_empty_arg(payload, "ordinal", "index", "object_index") + if ordinal_value not in {None, ""}: + return invalid_argument( + method, + "ordinal", + "Application-data methods require an object name, public ref, or GUID; ordinal selectors are not supported.", + ) + + selector_payload = dict(payload) + selector_payload.pop("record_ref", None) + selector_payload.pop("recorder_ref", None) + raw_ref = str(payload.get("ref") or "").replace("-", "").strip() + if isinstance(object_ref, str) and object_ref.strip(): + selector_payload["ref"] = object_ref.strip() + elif re.fullmatch(r"[0-9a-fA-F]{32}", raw_ref): + selector_payload.pop("ref", None) + + normalized_selector = normalize_object_selector_aliases(selector_payload, method) + if isinstance(normalized_selector, dict) and normalized_selector.get("status") == "invalid_argument": + return normalized_selector + result = dict(payload) + for key in ("kind", "name", "guid"): + if normalized_selector.get(key) not in {None, ""}: + result[key] = normalized_selector[key] + return result + + +def normalize_selector_payload_for_method(payload: dict[str, Any], method: str) -> dict[str, Any]: + capabilities = OBJECT_SELECTOR_METHOD_CAPABILITIES.get(method) or {} + ordinal_value = first_non_empty_arg(payload, "ordinal", "index", "object_index") + if capabilities.get("accepts_ordinal") is False and ordinal_value not in {None, ""}: + return invalid_argument( + method, + "ordinal", + "This method requires an object name, public ref, or GUID; ordinal selectors are not supported.", + ) + if method in DATA_OBJECT_SELECTOR_METHODS: + return normalize_data_object_selector_aliases(payload, method) + return normalize_object_selector_aliases(payload, method) + + def has_object_selector(payload: dict[str, Any]) -> bool: ordinal_value = first_non_empty_arg(payload, "ordinal", "index", "object_index") return bool( @@ -1402,6 +2252,8 @@ OBJECT_SELECTOR_METHOD_CAPABILITIES = { "metadata.objects.list", "metadata.object.get", "metadata.object.properties", + "metadata.object.property.write", + "metadata.object.member.add", "metadata.object.decode", "metadata.object.parts", "metadata.object.modules", @@ -1420,10 +2272,24 @@ OBJECT_SELECTOR_METHOD_CAPABILITIES = { "metadata.resolve_overrides", "templates.bindings", "diagnostics.call_chain", + "access.object.roles", + "access.object.subjects", ) } OBJECT_SELECTOR_METHOD_CAPABILITIES.update( { + "metadata.object.property.write": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": True, + }, + "metadata.object.member.add": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + }, "metadata.objects.list": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_extension": False, @@ -1458,6 +2324,23 @@ OBJECT_SELECTOR_METHOD_CAPABILITIES.update( "accepts_module_ref": True, "accepts_extension": True, }, + "metadata.saved_state.diff": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_module_ref": True, + "accepts_extension": True, + "requires_object_selector": False, + "module_selector_arguments": ["module_ordinal", "module_index", "module_number"], + "read_only": True, + }, + "metadata.module_owner_cache.prune": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_module_ref": True, + "requires_object_selector": False, + "selector_purpose": "cache_owner_scope", + "local_cache_mutation": True, + }, "modules.search": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_extension": True, @@ -1483,9 +2366,243 @@ OBJECT_SELECTOR_METHOD_CAPABILITIES.update( "accepts_module_ref": True, "requires_object_selector": False, }, + "metadata.support.decode": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "requires_object_selector": False, + "selector_purpose": "optional_object_support_filter", + }, + "metadata.code_index.build": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "allows_global_search": True, + "requires_object_selector": False, + "selector_purpose": "optional_index_scope", + }, + "metadata.form.owner_index.build": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "optional_form_owner_scope", + "form_selector_arguments": ["form", "form_name"], + }, + "metadata.form.command_button.verify": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "optional_form_owner_scope", + "form_selector_arguments": ["form", "form_name"], + "child_selector_arguments": ["command_name", "button_name", "handler_name"], + "read_only": True, + }, + "metadata.form.command_button.write": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "optional_form_owner_scope", + "form_selector_arguments": ["form", "form_name", "form_guid"], + "child_selector_arguments": ["command_name", "button_name", "handler_name"], + "read_only": False, + "write_guards": [ + "allow_saved_state_write", + "allow_sql_saved_state_apply", + "repository_apply_gate", + "verified_saved_state_target", + ], + }, + "metadata.form.write_target.resolve": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "optional_form_owner_scope", + "form_selector_arguments": ["form", "form_name", "form_guid"], + "child_selector_arguments": ["element", "element_name", "command", "attribute"], + "read_only": True, + }, + "metadata.form.write_target.verify": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "optional_form_owner_scope", + "form_selector_arguments": ["form", "form_name", "form_guid"], + "child_selector_arguments": ["element", "element_name", "command", "attribute"], + "read_only": True, + }, + "metadata.form.element.write": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "optional_form_owner_scope", + "form_selector_arguments": ["form", "form_name", "form_guid"], + "child_selector_arguments": ["element", "element_name", "command", "attribute"], + "read_only": False, + "write_guards": ["allow_saved_state_write", "proposal_only"], + }, + "metadata.form.element.write_apply": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "optional_form_owner_scope", + "form_selector_arguments": ["form", "form_name", "form_guid"], + "child_selector_arguments": ["element", "element_name", "command", "attribute"], + "read_only": False, + "write_guards": [ + "allow_saved_state_write", + "allow_sql_saved_state_apply", + "repository_apply_gate", + "verified_saved_state_target", + ], + }, + "metadata.form.target.move": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "optional_form_owner_scope", + "form_selector_arguments": ["form", "form_name", "form_guid"], + "child_selector_arguments": ["from_element", "to_element", "with_element", "after_element"], + "read_only": False, + "write_guards": [ + "allow_saved_state_write", + "allow_sql_saved_state_apply", + "repository_apply_gate", + "verified_saved_state_target", + ], + }, + "metadata.form.write_matrix.build": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "optional_form_owner_scope", + "form_selector_arguments": ["form", "form_name", "form_guid"], + "child_selector_arguments": ["element", "element_name", "command", "attribute"], + "read_only": True, + }, + "metadata.form.write_matrix.smoke": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "optional_form_owner_scope", + "form_selector_arguments": ["form", "form_name", "form_guid"], + "child_selector_arguments": ["element", "element_name", "command", "attribute"], + "read_only": False, + "write_guards": [ + "allow_sql_saved_state_apply", + "allow_sql_saved_state_rollback", + "apply_and_rollback_only", + ], + }, + "metadata.write_learning.capture_before": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "optional_form_owner_scope", + "form_selector_arguments": ["form", "form_name", "form_guid"], + "child_selector_arguments": ["element", "element_name", "command", "attribute"], + "read_only": False, + "write_guards": ["local_artifact_only", "no_sql_write"], + }, + "metadata.write_learning.capture_after": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "optional_form_owner_scope", + "form_selector_arguments": ["form", "form_name", "form_guid"], + "child_selector_arguments": ["element", "element_name", "command", "attribute"], + "read_only": False, + "write_guards": ["local_artifact_only", "no_sql_write"], + }, + "metadata.saved_state.forms.search": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "optional_form_owner_scope", + "form_selector_arguments": ["form", "form_name", "form_guid"], + "child_selector_arguments": ["element", "element_name", "command", "attribute"], + "read_only": True, + }, + "metadata.saved_state.modules.search": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "allows_global_search": True, + "requires_object_selector": False, + "selector_purpose": "optional_module_owner_scope", + "module_selector_arguments": ["file_name", "stream_index"], + "read_only": True, + }, + "code.write": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "accepts_module_ref": True, + "requires_object_selector": False, + "selector_purpose": "module_owner_or_concrete_module", + "form_selector_arguments": ["form", "form_name"], + "child_selector_arguments": ["routine_name"], + "read_only": False, + "write_guards": [ + "save_first", + "allow_sql_saved_state_apply", + "repository_apply_gate", + "verified_saved_state_target", + ], + }, + "templates.areas.find": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "accepts_extension": True, + "requires_object_selector": False, + "selector_purpose": "template_or_direct_route", + "child_selector_arguments": ["area_name", "area_query", "area_occurrence"], + "direct_route_arguments": ["route_ref", "table", "file_name"], + "read_only": True, + }, + "metadata.cache.lookup": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "requires_object_selector": True, + "selector_purpose": "local_identity_cache_lookup", + "read_only": True, + "authoritative_source": "live_sql_not_cache", + }, + **{ + method: { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "requires_object_selector": True, + "accepts_object_ref": True, + "record_ref_argument": ( + "recorder_ref" + if method == "data.movements" + else "record_ref" + if method in {"data.get", "data.present"} + else None + ), + } + for method in DATA_OBJECT_SELECTOR_METHODS + }, } ) OBJECT_SELECTOR_ALIAS_METHODS = frozenset(OBJECT_SELECTOR_METHOD_CAPABILITIES) +DEFAULT_METHOD_INPUT_SCHEMA = { + "type": "object", + "additionalProperties": True, + "description": "Method-specific JSON payload. See the method description and selector_capabilities.", +} def object_selector_capabilities(method: str) -> dict[str, Any] | None: @@ -1507,9 +2624,138 @@ def public_method_row(row: dict[str, Any]) -> dict[str, Any]: selector_capabilities = object_selector_capabilities(name) if selector_capabilities is not None: public["selector_capabilities"] = selector_capabilities + public["input_schema"] = copy.deepcopy(METHOD_INPUT_SCHEMAS.get(name) or DEFAULT_METHOD_INPUT_SCHEMA) + specialized_routes = adapter_http_routes_for_method(name) + if specialized_routes: + public["http_routes"] = specialized_routes return public +HTTP_GET_METHOD_ROUTES = { + "/health": "health", + "/methods": "help.methods", + "/metadata/kinds": "metadata.kinds", + "/metadata/objects": "metadata.objects.list", + "/metadata/object": "metadata.object.get", + "/extensions": "extensions.list", + "/modules/read": "modules.read", +} + +HTTP_POST_METHOD_ROUTES = { + "/metadata/snapshot": "metadata.snapshot", + "/metadata/object/decode": "metadata.object.decode", + "/metadata/object/parts": "metadata.object.parts", + "/metadata/object/modules": "metadata.object.modules", + "/metadata/object/related": "metadata.object.related", + "/metadata/object/forms": "metadata.object.forms", + "/metadata/object/form-details": "metadata.object.form.details", + "/metadata/object/templates": "metadata.object.templates", + "/metadata/object/template-details": "metadata.object.template.details", + "/templates/read": "templates.read", + "/templates/analyze": "templates.analyze", + "/templates/map": "templates.map", + "/metadata/object/commands": "metadata.object.commands", + "/metadata/route/resolve": "metadata.route.resolve", + "/metadata/object/special-details": "metadata.object.special.details", + "/metadata/form/decode": "metadata.form.decode", + "/metadata/form/write-target/resolve": "metadata.form.write_target.resolve", + "/metadata/saved-state/forms/search": "metadata.saved_state.forms.search", + "/metadata/form/element-write": "metadata.form.element.write", + "/metadata/form/element-write-apply": "metadata.form.element.write_apply", + "/metadata/write-plan": "metadata.write.plan", + "/metadata/write-capabilities": "metadata.write.capabilities", + "/metadata/write": "metadata.write", + "/metadata/write-learning/capture-before": "metadata.write_learning.capture_before", + "/metadata/write-learning/capture-after": "metadata.write_learning.capture_after", + "/metadata/write-learning/diff": "metadata.write_learning.diff", + "/metadata/write-learning/infer-rule": "metadata.write_learning.infer_rule", + "/code/write": "code.write", + "/metadata/object/attributes": "metadata.object.attributes", + "/metadata/object/full": "metadata.object.full", + "/metadata/resolve-overrides": "metadata.resolve_overrides", + "/modules/search": "modules.search", + "/code/search": "code.search", + "/code/read": "code.read", + "/code/symbol/resolve": "code.symbol.resolve", + "/templates/bindings": "templates.bindings", + "/diagnostics/call-chain": "diagnostics.call_chain", + "/metadata/module-owner-cache/prune": "metadata.module_owner_cache.prune", + "/access/snapshot/extract": "access.snapshot.extract", + "/access/graph": "access.graph.build", + "/access/user/explain": "access.user.explain", + "/access/users/search": "access.users.search", + "/access/keys/query": "access.keys.query", + "/access/object-keys/resolve": "access.object_keys.resolve", + "/access/object/explain": "access.object.explain", + "/access/object/roles": "access.object.roles", + "/access/object/subjects": "access.object.subjects", + "/access/rls/discover": "access.rls.discover", + "/access/role/profiles": "access.role.profiles", + "/access/role/users": "access.role.users", + "/access/role/audit-export": "access.role.audit_export", + "/access/role/audit-analyze": "access.role.audit_analyze", + "/extension/objects/find": "extension.objects.find", + "/query/validate": "query.validate", + "/query/run": "query.run", + "/codec/decode": "codec.decode", + "/codec/encode": "codec.encode", + "/changes/propose": "changes.propose", + "/storage/saved-state/apply-proposal": "storage.saved_state.apply_proposal", + "/storage/saved-state/rollback": "storage.saved_state.rollback", + "/storage/saved-state/backups": "storage.saved_state.backups.list", +} + + +def adapter_http_routes_for_method(method: str) -> list[dict[str, str]]: + routes = [ + {"verb": "GET", "path": path} + for path, route_method in HTTP_GET_METHOD_ROUTES.items() + if route_method == method + ] + routes.extend( + {"verb": "POST", "path": path} + for path, route_method in HTTP_POST_METHOD_ROUTES.items() + if route_method == method + ) + return routes + + +def adapter_method_registry() -> dict[str, dict[str, Any]]: + """Single runtime registry used by help, jobs, RPC validation, and MCP checks.""" + + return { + str(row.get("name") or ""): public_method_row(row) + for row in METHODS + if str(row.get("name") or "") + } + + +def adapter_method_registry_diagnostics() -> dict[str, Any]: + names = [str(row.get("name") or "") for row in METHODS] + duplicates = sorted({name for name in names if name and names.count(name) > 1}) + registered = set(adapter_method_registry()) + route_methods = set(HTTP_GET_METHOD_ROUTES.values()) | set(HTTP_POST_METHOD_ROUTES.values()) + return { + "status": "ok" + if not duplicates + and set(METHOD_INPUT_SCHEMAS) <= registered + and set(OBJECT_SELECTOR_METHOD_CAPABILITIES) <= registered + and route_methods <= registered + else "invalid", + "counts": { + "methods": len(registered), + "methods_with_input_schema": sum(1 for row in adapter_method_registry().values() if isinstance(row.get("input_schema"), dict)), + "explicit_input_schemas": len(METHOD_INPUT_SCHEMAS), + "selector_contracts": len(OBJECT_SELECTOR_METHOD_CAPABILITIES), + "specialized_http_routes": len(HTTP_GET_METHOD_ROUTES) + len(HTTP_POST_METHOD_ROUTES), + }, + "duplicates": duplicates, + "input_schemas_without_method": sorted(set(METHOD_INPUT_SCHEMAS) - registered), + "selector_contracts_without_method": sorted(set(OBJECT_SELECTOR_METHOD_CAPABILITIES) - registered), + "http_routes_without_method": sorted(route_methods - registered), + } + + def is_extension_path(path: str | None) -> bool: parts = [part.casefold() for part in str(path or "").replace("/", "\\").split("\\")] return "расширения" in parts or "extensions" in parts @@ -1858,11 +3104,13 @@ METADATA_WRITE_CAPABILITIES = { "gaps": ["Complex command handler bindings and inherited form properties are not fully writable yet."], }, "object_metadata": { - "status": "planned", - "targets": ["attributes", "tabular_sections", "commands"], - "operations": [], + "status": "partial_saved_state", + "targets": ["object_identity", "attributes", "tabular_sections", "commands"], + "operations": ["write_synonym", "write_comment", "write_member_synonym", "write_member_comment", "add_attribute_from_template"], + "agent_method": "metadata.write", "write_layer": "save", - "gaps": ["Need safe tree patching rules per object kind before enabling writes."], + "guards": ["expected_old", "expected_sha1", "backup", "semantic_readback"], + "gaps": ["Attribute add currently requires an existing Attribute template in the same collection. Object/member rename, arbitrary type construction, deletion, and other structural collection writes remain disabled."], }, "templates": { "status": "read_only", @@ -1872,11 +3120,13 @@ METADATA_WRITE_CAPABILITIES = { "gaps": ["Template rendering/round-trip encoding is not complete enough for safe writes."], }, "scheduled_jobs": { - "status": "read_only_reference", + "status": "partial_saved_state", "targets": ["schedule", "method_reference"], - "operations": [], - "write_layer": None, - "gaps": ["Executable code is edited through the referenced module; schedule edits are not routed yet."], + "operations": ["plan_schedule_write", "apply_schedule_write", "verify_schedule_write", "rollback_schedule_write"], + "agent_method": "metadata.write", + "write_layer": "save", + "guards": ["expected_sha1", "backup", "readback_verification"], + "gaps": ["The method reference remains read-only here; edit executable code through the resolved module selector."], }, "web_http_services": { "status": "read_only", @@ -1968,13 +3218,24 @@ def public_module_role( return {"kind": "object_module", "name": display_name} -def public_module_row(module: dict[str, Any], *, include_storage: bool = False, ordinal: int | None = None, owner_kind: str | None = None) -> dict[str, Any]: +def public_module_row( + module: dict[str, Any], + *, + include_storage: bool = False, + ordinal: int | None = None, + owner_kind: str | None = None, + owner_name: str | None = None, +) -> dict[str, Any]: public = dict(module) + kind = canonical_kind(str(owner_kind or "")) role = public_module_role( - owner_kind=owner_kind, + owner_kind=kind, suffix=str(public.get("suffix") or module_suffix_from_module_id(public.get("module_id")) or ""), ordinal=ordinal, - current_name=str(public.get("name") or ""), + # A common module is addressed in 1C code by the metadata object name. + # The raw row name is a Config/ConfigCAS stream coordinate and must + # never become its public name. + current_name=str(owner_name or "") if kind == "CommonModule" else None, ) original_name = public.get("name") public["name"] = role.get("name") @@ -1984,7 +3245,19 @@ def public_module_row(module: dict[str, Any], *, include_storage: bool = False, if include_storage and original_name and original_name != public.get("name"): public["storage_name"] = original_name if not include_storage: - for key in ("module_id", "source", "table", "file_name", "suffix", "stream_index", "bytes", "sha1", "encoding", "payload_role"): + for key in ( + "module_id", + "source", + "table", + "file_name", + "suffix", + "stream_index", + "bytes", + "sha1", + "encoding", + "payload_role", + "storage_name", + ): public.pop(key, None) return public @@ -1998,6 +3271,12 @@ def public_code_qualified_name( owner_name = str((owner or {}).get("name") or "").strip() form_name = str((form or {}).get("name") or "").strip() module_name = str((module or {}).get("name") or "").strip() + if ( + str((module or {}).get("kind") or "").strip() == "common_module" + and owner_name + and normalize(module_name) == normalize(owner_name) + ): + return owner_name parts = [part for part in (owner_name, form_name, module_name) if part] return ".".join(parts) if parts else None @@ -2010,7 +3289,13 @@ def public_module_with_qualified_name( ordinal: int | None = None, owner_kind: str | None = None, ) -> dict[str, Any]: - public = public_module_row(module, include_storage=include_storage, ordinal=ordinal, owner_kind=owner_kind or ((owner or {}).get("kind") if isinstance(owner, dict) else None)) + public = public_module_row( + module, + include_storage=include_storage, + ordinal=ordinal, + owner_kind=owner_kind or ((owner or {}).get("kind") if isinstance(owner, dict) else None), + owner_name=(owner or {}).get("name") if isinstance(owner, dict) else None, + ) qualified_name = public_code_qualified_name(owner=owner, module=public) if qualified_name: public["qualified_name"] = qualified_name @@ -3598,74 +4883,6 @@ def integration_service_sql_details(tree: Any, *, include_storage: bool) -> dict } -def config_schedule_datetime(raw: str) -> tuple[str | None, str | None]: - if not re.fullmatch(r"\d{14}", raw): - return None, None - date_value = f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]}" - time_value = f"{raw[8:10]}:{raw[10:12]}:{raw[12:14]}" - return date_value, time_value - - -def scheduled_job_sql_schedule(tree: Any, *, include_storage: bool) -> dict[str, Any]: - items = tree.get("items") if isinstance(tree, dict) and isinstance(tree.get("items"), list) else [] - raw = [config_tree_scalar(item) for item in items] - if len(raw) < 13: - return {"status": "invalid_schedule_payload", "diagnostics": {"message": "The SQL schedule payload is shorter than the supported format."}} - cursor = 0 - begin_date, _ = config_schedule_datetime(raw[cursor]); cursor += 1 - end_date, _ = config_schedule_datetime(raw[cursor]); cursor += 1 - _, begin_time = config_schedule_datetime(raw[cursor]); cursor += 1 - _, end_time = config_schedule_datetime(raw[cursor]); cursor += 1 - _, completion_time = config_schedule_datetime(raw[cursor]); cursor += 1 - - def next_int() -> int | None: - nonlocal cursor - if cursor >= len(raw): - return None - value = int(raw[cursor]) if re.fullmatch(r"-?\d+", raw[cursor]) else None - cursor += 1 - return value - - completion_interval = next_int() - repeat_period_in_day = next_int() - repeat_pause = next_int() - week_day_count = next_int() - if week_day_count is None or week_day_count < 0 or cursor + week_day_count > len(raw): - return {"status": "invalid_schedule_payload", "diagnostics": {"message": "Invalid weekday collection in the SQL schedule payload."}} - week_days = [int(value) for value in raw[cursor : cursor + week_day_count] if value.isdigit()] - cursor += week_day_count - week_day_in_month = next_int() - day_in_month = next_int() - month_count = next_int() - if month_count is None or month_count < 0 or cursor + month_count > len(raw): - return {"status": "invalid_schedule_payload", "diagnostics": {"message": "Invalid month collection in the SQL schedule payload."}} - months = [int(value) for value in raw[cursor : cursor + month_count] if value.isdigit()] - cursor += month_count - weeks_period = next_int() - days_repeat_period = next_int() - result = { - "status": "ok", - "begin_date": begin_date, - "end_date": end_date, - "begin_time": begin_time, - "end_time": end_time, - "completion_time": completion_time, - "completion_interval": completion_interval, - "repeat_period_in_day": repeat_period_in_day, - "repeat_pause": repeat_pause, - "week_days": week_days, - "week_day_in_month": week_day_in_month, - "day_in_month": day_in_month, - "months": months, - "weeks_period": weeks_period, - "days_repeat_period": days_repeat_period, - "evidence": "live_sql_config_schedule_decoder", - } - if include_storage: - result["storage"] = {"format": "scheduled_job_config_suffix_0", "raw_values": raw, "trailing_values": raw[cursor:]} - return result - - def public_pattern_value_type( base_id: str, type_node: Any, @@ -4168,7 +5385,7 @@ def ws_reference_sql_details(tree: Any, definition_data: bytes) -> dict[str, Any def common_command_sql_details(base_id: str, tree: Any, identity: dict[str, Any], *, table: str, timeout_seconds: int) -> dict[str, Any]: body = config_tree_item_at_path(tree, (1, 1, 2)) - group_guid = config_tree_scalar_at_path(body, (7, 1)).strip().lower() + group_guid = common_command_group_guid(tree) or "" group_map = public_metadata_guid_references(base_id, [group_guid], table=table, timeout_seconds=timeout_seconds) if is_guid_text(group_guid) else {} group = group_map.get(group_guid) if not isinstance(group, dict) or not group.get("ref"): @@ -4538,8 +5755,22 @@ def classify_unmapped_source_role(role: str) -> str: "SystemSettings", }: return "platform_system_storage" - if role.startswith(("STT",)) or role in {"Acoustic", "Bots", "Ecs", "ExtDataSrcPrms", "LangModel", "MobileClientDataExchange", "URLExternalData", "WebSocketClients"}: - return "platform_feature_candidate" + # Live DBNames verification shows these as singleton platform tables + # (normally with the zero GUID), not configuration metadata collections. + # ExtDataSrcPrms is storage attached to an ExternalDataSource or its global + # fallback row, so it is auxiliary rather than a standalone metadata kind. + if role == "ExtDataSrcPrms": + return "auxiliary_storage" + if role.startswith(("STT",)) or role in { + "Acoustic", + "Bots", + "Ecs", + "LangModel", + "MobileClientDataExchange", + "URLExternalData", + "WebSocketClients", + }: + return "platform_system_storage" return "unclassified" @@ -4619,6 +5850,7 @@ def metadata_adapter_audit(payload: dict[str, Any]) -> dict[str, Any]: "kind": kind, "kind_ru": RU_KIND.get(kind, kind), "public_kind": public_kind, + "presence_status": "supported_absent_in_selected_base", "capabilities": KIND_CAPABILITIES[kind], } ) @@ -6540,7 +7772,17 @@ ACCESS_KEY_QUERY_AREAS = { } -ACCESS_RECORD_TABLE_PREFIXES = ["_Reference", "_Document", "_BPr", "_Task", "_Enum"] +ACCESS_RECORD_TABLE_PREFIXES = [ + "_Reference", + "_Document", + "_BPr", + "_Task", + "_Enum", + "_Acc", + "_CKinds", + "_Chrc", + "_Node", +] def access_sql_string_literal(value: Any) -> str: @@ -6551,6 +7793,106 @@ def access_record_table_candidates(sql_number: int) -> list[str]: return [f"{prefix}{sql_number}" for prefix in ACCESS_RECORD_TABLE_PREFIXES] +def normalize_access_object_record_filters( + payload: dict[str, Any], + method: str, + base_id: str, + *, + timeout_seconds: int, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any] | None]: + """Resolve public metadata/record refs without overloading access.keys.query kind.""" + + argument_error = validate_optional_string_arguments(payload, method, ["object_ref", "record_ref"]) + if argument_error: + return payload, {}, argument_error + normalized = dict(payload) + resolution: dict[str, Any] = {} + + object_ref = str(payload.get("object_ref") or "").strip() + if object_ref: + schema_result = data_object_schema( + { + "base_id": base_id, + "object_ref": object_ref, + "timeout_seconds": timeout_seconds, + } + ) + if schema_result.get("status") != "ok": + error = dict(schema_result) + error["method"] = method + error.setdefault("argument", "object_ref") + return payload, {}, error + object_card = schema_result.get("object") if isinstance(schema_result.get("object"), dict) else {} + object_kind = str(object_card.get("kind") or "") + table = schema_result.get("table") if isinstance(schema_result.get("table"), dict) else {} + table_name = str(table.get("name") or "") + expected_prefix = DATA_TABLE_PREFIXES.get(object_kind) + table_match = re.fullmatch(rf"{re.escape(expected_prefix or '')}(\d+)", table_name) if expected_prefix else None + if not table_match: + return payload, {}, { + "schema": "onec_adapter_request_error.v1", + "method": method, + "status": "unsupported_kind", + "error": "unsupported_kind", + "argument": "object_ref", + "base_id": base_id, + "object": object_card, + "diagnostics": { + "message": "The selected metadata object has no BSP application-record route.", + }, + } + object_sql_number = int(table_match.group(1)) + explicit_sql_number = payload.get("object_sql_number") + if explicit_sql_number not in {None, ""}: + try: + explicit_sql_number = int(explicit_sql_number) + except (TypeError, ValueError): + return payload, {}, invalid_argument(method, "object_sql_number", "object_sql_number must be an integer.") + if explicit_sql_number != object_sql_number: + return payload, {}, invalid_argument( + method, + "object_sql_number", + "object_sql_number conflicts with the metadata object resolved from object_ref.", + ) + normalized["object_sql_number"] = object_sql_number + resolution["metadata_object"] = { + "input": object_ref, + "ref": object_selector_ref(object_kind, object_card.get("name")) or object_ref, + "kind": object_kind or None, + "name": object_card.get("name"), + "guid": object_card.get("guid"), + "object_sql_number": object_sql_number, + } + + record_ref = str(payload.get("record_ref") or "").strip() + if record_ref: + record_parts = access_identifier_parts(record_ref) + if not record_parts: + return payload, {}, invalid_argument( + method, + "record_ref", + "record_ref must contain exactly one 32-hex-character 1C application-data reference.", + ) + record_id = record_parts["ref"] + explicit_object_id = payload.get("object_id") + if explicit_object_id not in {None, ""}: + explicit_record_id = access_ref_tail(explicit_object_id) + if explicit_record_id != record_id: + return payload, {}, invalid_argument( + method, + "object_id", + "object_id conflicts with record_ref.", + ) + normalized["object_id"] = record_id + resolution["data_record"] = { + "input": record_ref, + "record_ref": record_id, + **({"type_code": record_parts["type_code"]} if record_parts.get("type_code") else {}), + } + + return normalized, resolution, None + + def access_resolve_object_key_records(base_id: str, rows: list[dict[str, Any]], *, timeout_seconds: int = 60, max_records: int = 200) -> dict[str, Any]: wanted: dict[int, set[str]] = {} for row in rows: @@ -6735,7 +8077,27 @@ def access_key_query_run_area( "area": area, "rows": mapped, "counts": {"rows": len(mapped), "limit": limit, "offset": offset, "truncated": bool(truncated)}, - "filters": {key: payload.get(key) for key in ("group", "group_id", "user", "user_set", "object", "object_id", "access_set", "set", "key", "access_key", "query", "name") if payload.get(key) not in {None, ""}}, + "filters": { + key: payload.get(key) + for key in ( + "group", + "group_id", + "user", + "user_set", + "object_ref", + "record_ref", + "object", + "object_id", + "object_sql_number", + "access_set", + "set", + "key", + "access_key", + "query", + "name", + ) + if payload.get(key) not in {None, ""} + }, } @@ -6763,13 +8125,24 @@ def access_keys_query(payload: dict[str, Any]) -> dict[str, Any]: raw_kind = str(payload.get("kind") or payload.get("area") or "all").strip().casefold() if method == "access.object_keys.resolve" and raw_kind in {"", "all", "*"}: raw_kind = "object" - queries, mappings, diagnostics = access_bsp_extractor_plan(base_id) - if not queries or not mappings: - return {"schema": "onec_access_keys_query.v1", "status": "error", "base_id": base_id, "diagnostics": (diagnostics or {}).get("diagnostics") or diagnostics} areas = list(ACCESS_KEY_QUERY_AREAS.values()) if raw_kind in {"", "all", "*"} else [ACCESS_KEY_QUERY_AREAS.get(raw_kind, "")] areas = [area for area in dict.fromkeys(areas) if area] if not areas: return invalid_argument(method, "kind", "kind must be one of group, user_set, object, set, or all.", allowed_values=["group", "user_set", "object", "set", "all"]) + selector_resolution: dict[str, Any] = {} + if "access_object_keys" in areas: + normalized_payload, selector_resolution, selector_error = normalize_access_object_record_filters( + payload, + method, + base_id, + timeout_seconds=int(timeout_seconds), + ) + if selector_error: + return selector_error + payload = normalized_payload + queries, mappings, diagnostics = access_bsp_extractor_plan(base_id) + if not queries or not mappings: + return {"schema": "onec_access_keys_query.v1", "status": "error", "base_id": base_id, "diagnostics": (diagnostics or {}).get("diagnostics") or diagnostics} results: dict[str, Any] = {} for area in areas: result = access_key_query_run_area( @@ -6801,6 +8174,7 @@ def access_keys_query(payload: dict[str, Any]) -> dict[str, Any]: "source": {"kind": "live_sql", "preset": "bsp"}, "areas": results, "counts": {area: result.get("counts") for area, result in results.items()}, + **({"selector_resolution": selector_resolution} if selector_resolution else {}), "diagnostics": diagnostics, } @@ -6815,8 +8189,12 @@ def access_object_explain(payload: dict[str, Any]) -> dict[str, Any]: if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error - if not any(payload.get(key) not in {None, ""} for key in ("object", "object_id", "access_key")): - return invalid_argument(method, "object", "Pass object, object_id, or access_key to explain object access.") + if not any(payload.get(key) not in {None, ""} for key in ("object_ref", "record_ref", "object", "object_id", "access_key")): + return invalid_argument( + method, + "record_ref", + "Pass object_ref and/or record_ref, or a legacy raw object, object_id, or access_key filter.", + ) limit, limit_error = parse_int_argument(payload, "limit", method=method, default=1000, minimum=1, maximum=20000) if limit_error: return limit_error @@ -6829,6 +8207,15 @@ def access_object_explain(payload: dict[str, Any]) -> dict[str, Any]: max_resolved_records, max_resolved_records_error = parse_int_argument(payload, "max_resolved_records", method=method, default=200, minimum=0, maximum=5000) if max_resolved_records_error: return max_resolved_records_error + normalized_payload, selector_resolution, selector_error = normalize_access_object_record_filters( + payload, + method, + base_id, + timeout_seconds=int(timeout_seconds), + ) + if selector_error: + return selector_error + payload = normalized_payload queries, mappings, diagnostics = access_bsp_extractor_plan(base_id) if not queries or not mappings: @@ -6929,7 +8316,12 @@ def access_object_explain(payload: dict[str, Any]) -> dict[str, Any]: "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "preset": "bsp"}, - "query": {key: payload.get(key) for key in ("object", "object_id", "object_sql_number", "access_key") if payload.get(key) not in {None, ""}}, + "query": { + key: payload.get(key) + for key in ("object_ref", "record_ref", "object", "object_id", "object_sql_number", "access_key") + if payload.get(key) not in {None, ""} + }, + **({"selector_resolution": selector_resolution} if selector_resolution else {}), "summary": summary, "object_keys": object_rows, "access_keys": access_key_ids, @@ -11854,14 +13246,14 @@ def metadata_write_rollback(payload: dict[str, Any]) -> dict[str, Any]: if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error - repository_error = repository_apply_gate(payload, method, "apply") - if repository_error: - return repository_error allow_rollback, allow_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) if allow_error: return allow_error if not allow_rollback: return invalid_argument(method, "allow_sql_saved_state_rollback", "Saved-state rollback by operation is opt-in; pass allow_sql_saved_state_rollback=true.") + repository_error = repository_apply_gate(payload, method, "apply") + if repository_error: + return repository_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) if timeout_error: return timeout_error @@ -12439,7 +13831,17 @@ def metadata_code_index_build(payload: dict[str, Any]) -> dict[str, Any]: extension_guid = str(payload.get("extension_guid") or "").strip().lower() if "extension_guid" in payload and not is_guid_text(extension_guid): return invalid_argument(method, "extension_guid", "extension_guid must be a GUID string.") - table = str(payload.get("table") or ("ConfigCASSave" if extension_guid else "ConfigCAS")) + has_object_selector = bool(payload.get("guid") or payload.get("name")) + table = str( + payload.get("table") + or ( + "ConfigCASSave" + if extension_guid + else "Config" + if has_object_selector + else "ConfigCAS" + ) + ) if table not in STORAGE_TABLES: return invalid_argument(method, "table", "Unsupported storage table.", allowed_values=sorted(STORAGE_TABLES)) prefix = str(payload.get("prefix") or "") @@ -12462,7 +13864,6 @@ def metadata_code_index_build(payload: dict[str, Any]) -> dict[str, Any]: form_modules_discovered = 0 empty_form_modules = 0 form_module_errors = 0 - has_object_selector = bool(payload.get("guid") or payload.get("name")) if has_object_selector: object_result = get_object( payload.get("kind"), @@ -14120,13 +15521,17 @@ def metadata_form_owner_cache_lookup( extension: str | None = None, table: str | None = None, file_name: str | None = None, + form_guid: str | None = None, module_ref: str | None = None, ) -> dict[str, Any] | None: if not config: return None clauses = ["server_key=?", "database_name=?"] params: list[Any] = [cache_server_key(config), cache_database_name(config)] - if module_ref: + if form_guid: + clauses.append("form_guid=?") + params.append(str(form_guid).strip().lower()) + elif module_ref: normalized_ref = normalize_module_ref_for_form_owner(module_ref) clauses.append("module_ref=?") params.append(normalized_ref) @@ -14386,7 +15791,11 @@ def metadata_cache_status(payload: dict[str, Any]) -> dict[str, Any]: def metadata_cache_lookup(payload: dict[str, Any]) -> dict[str, Any]: - base_id_or_error = require_base_id(payload, "metadata.cache.lookup") + method = "metadata.cache.lookup" + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload + base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error for argument in ("guid", "kind", "name"): @@ -14457,11 +15866,46 @@ def metadata_cache_invalidate(payload: dict[str, Any]) -> dict[str, Any]: def metadata_module_owner_cache_prune(payload: dict[str, Any]) -> dict[str, Any]: - base_id_or_error = require_base_id(payload, "metadata.module_owner_cache.prune") + method = "metadata.module_owner_cache.prune" + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload + validation_error = validate_metadata_module_owner_cache_prune_payload(payload) + if validation_error: + return validation_error + base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error - owner_guid = str(payload.get("owner_guid") or "").strip() + timeout_seconds, _ = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=300) + include_storage, _ = strict_include_storage(payload, method) + owner_guid = str(payload.get("owner_guid") or "").strip().lower() + public_selector_used = not owner_guid and has_object_selector(payload) + owner_resolution: dict[str, Any] | None = None + if public_selector_used: + resolved_owner_guid, owner_resolution = metadata_saved_state_modules_owner_guid_from_selector( + base_id, + payload, + timeout_seconds=int(timeout_seconds or 30), + ) + owner_guid = str(resolved_owner_guid or "").strip().lower() + if not is_guid_text(owner_guid): + status = str((owner_resolution or {}).get("status") or "not_found") + return { + "schema": "onec_module_owner_cache_prune.v1", + "method": method, + "status": status if status in {"not_found", "ambiguous"} else "not_found", + "error": "owner_not_resolved", + "base_id": base_id, + "selector": { + **({"ref": payload.get("ref")} if payload.get("ref") else {}), + **({"kind": payload.get("kind")} if payload.get("kind") else {}), + **({"name": payload.get("name")} if payload.get("name") else {}), + }, + "owner_resolution": owner_resolution, + "diagnostics": (owner_resolution or {}).get("diagnostics") + or {"message": "The named 1C module owner was not resolved."}, + } module_ref = str(payload.get("module_ref") or "").strip() module_refs_raw = payload.get("module_refs") module_refs = [] @@ -14479,17 +15923,6 @@ def metadata_module_owner_cache_prune(payload: dict[str, Any]) -> dict[str, Any] module_refs = sorted({ref for ref in module_refs if ref}) elif module_refs_raw is not None: return invalid_argument("metadata.module_owner_cache.prune", "module_refs", "module_refs must be a JSON array of strings.") - if owner_guid and not is_guid_text(owner_guid): - return invalid_argument("metadata.module_owner_cache.prune", "owner_guid", "owner_guid must be a valid GUID JSON string.") - if not owner_guid and not module_ref and not module_refs: - return { - "schema": "onec_adapter_request_error.v1", - "method": "metadata.module_owner_cache.prune", - "status": "invalid_argument", - "error": "selector_required", - "argument": "owner_guid|module_ref|module_refs", - "diagnostics": {"message": "Pass at least one of: owner_guid, module_ref, or module_refs."}, - } dry_run, dry_run_error = strict_bool_argument(payload, "dry_run", method="metadata.module_owner_cache.prune", default=False) if dry_run_error: return dry_run_error @@ -14516,6 +15949,35 @@ def metadata_module_owner_cache_prune(payload: dict[str, Any]) -> dict[str, Any] params.extend(module_refs) where = " AND ".join(where_parts) query = f"SELECT COUNT(*) AS count FROM metadata_module_owner_cache WHERE {where}" + public_selector = { + **({"ref": payload.get("ref")} if payload.get("ref") else {}), + **({"kind": payload.get("kind")} if payload.get("kind") else {}), + **({"name": payload.get("name")} if payload.get("name") else {}), + } + resolved_owner = (owner_resolution or {}).get("object") if isinstance((owner_resolution or {}).get("object"), dict) else {} + public_owner = { + **({"kind": resolved_owner.get("kind")} if resolved_owner.get("kind") else {}), + **({"name": resolved_owner.get("name")} if resolved_owner.get("name") else {}), + **({"synonym": resolved_owner.get("synonym")} if resolved_owner.get("synonym") else {}), + **( + { + "ref": resolved_owner.get("ref") + or object_selector_ref(resolved_owner.get("kind"), resolved_owner.get("name")) + } + if resolved_owner.get("ref") or (resolved_owner.get("kind") and resolved_owner.get("name")) + else {} + ), + } + public_query = ( + { + "owner": public_owner or public_selector, + **({"module_ref": module_ref} if module_ref else {}), + **({"module_refs": module_refs} if module_refs else {}), + **({"storage": {"owner_guid": owner_guid}} if include_storage else {}), + } + if public_selector_used + else {"owner_guid": owner_guid or None, "module_ref": module_ref or None, "module_refs": module_refs or None} + ) with cache_connection() as conn: matched = int(conn.execute(query, params).fetchone()["count"]) if bool(dry_run): @@ -14525,7 +15987,8 @@ def metadata_module_owner_cache_prune(payload: dict[str, Any]) -> dict[str, Any] "base_id": base_id, "dry_run": True, "counts": {"matched": matched}, - "query": {"owner_guid": owner_guid or None, "module_ref": module_ref or None, "module_refs": module_refs or None}, + "query": public_query, + **({"resolved_by": "public_object_selector"} if public_selector_used else {}), } cursor = conn.execute(f"DELETE FROM metadata_module_owner_cache WHERE {where}", params) deleted = int(cursor.rowcount or 0) @@ -14535,7 +15998,8 @@ def metadata_module_owner_cache_prune(payload: dict[str, Any]) -> dict[str, Any] "base_id": base_id, "dry_run": False, "counts": {"matched": matched, "deleted": deleted}, - "query": {"owner_guid": owner_guid or None, "module_ref": module_ref or None, "module_refs": module_refs or None}, + "query": public_query, + **({"resolved_by": "public_object_selector"} if public_selector_used else {}), } @@ -15175,8 +16639,634 @@ def read_storage_file_bytes(base_id: str, table: str, file_name: str, *, timeout return b"".join(parts), config, None +BASE_SUPPORT_CONFIGURATION_ID_PATH = (3, 1, 1, 1, 1, 1, 2) + + +def discover_base_support_source( + base_id: str, + *, + timeout_seconds: int = 30, +) -> tuple[dict[str, str] | None, dict[str, Any] | None]: + """Resolve the base configuration ParentConfigurations payload from live SQL.""" + pointer_data, config, pointer_error = read_storage_file_bytes( + base_id, "Config", "root", timeout_seconds=timeout_seconds + ) + if pointer_error: + return None, public_error_result(pointer_error, method="metadata.support.decode") + pointer_tree = parse_config_tree_from_bytes(pointer_data or b"") + pointer_items = pointer_tree.get("items") if isinstance(pointer_tree, dict) else [] + root_file = next( + ( + config_tree_scalar(item).lower() + for item in (pointer_items or [])[1:] + if is_guid_text(config_tree_scalar(item)) + ), + "", + ) + if not root_file: + return None, { + "schema": "onec_support_rules.v1", + "method": "metadata.support.decode", + "status": "source_unknown", + "base_id": base_id, + "diagnostics": { + "message": "Config/root does not contain a valid configuration root descriptor GUID.", + "permission_inference": "forbidden", + }, + } + root_data, _, root_error = read_storage_file_bytes( + base_id, "Config", root_file, timeout_seconds=timeout_seconds + ) + if root_error: + return None, public_error_result(root_error, method="metadata.support.decode") + root_tree = parse_config_tree_from_bytes(root_data or b"") + configuration_id = config_tree_scalar_at_path(root_tree, BASE_SUPPORT_CONFIGURATION_ID_PATH).lower() + if not is_guid_text(configuration_id): + return None, { + "schema": "onec_support_rules.v1", + "method": "metadata.support.decode", + "status": "source_unknown", + "base_id": base_id, + "diagnostics": { + "message": "The base configuration descriptor does not contain the expected internal configuration GUID.", + "root_file": root_file, + "configuration_id_path": ".".join(map(str, BASE_SUPPORT_CONFIGURATION_ID_PATH)), + "permission_inference": "forbidden", + }, + } + return { + "kind": "live_sql", + "database": str((config or {}).get("database") or ""), + "table": "Config", + "file_name": f"{configuration_id}.4", + "root_file": root_file, + "configuration_id": configuration_id, + "discovery": "base_root_descriptor", + }, None + + +def discover_extension_support_source( + base_id: str, + extension_guid: str, + *, + timeout_seconds: int = 30, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """Resolve an extension ParentConfigurations payload from its complete CAS manifest.""" + manifests, diagnostics = live_extension_manifests( + base_id, + extension_guid=extension_guid, + timeout_seconds=timeout_seconds, + ) + manifest = next( + ( + item + for item in manifests + if str((item.get("extension") or {}).get("guid") or "").lower() == extension_guid.lower() + ), + None, + ) + if not manifest: + return None, { + "schema": "onec_support_rules.v1", + "method": "metadata.support.decode", + "status": "source_unknown", + "base_id": base_id, + "layer_id": f"extension:{extension_guid}", + "diagnostics": { + "message": "The extension CAS manifest could not be resolved from live SQL.", + "manifest_diagnostics": diagnostics, + "permission_inference": "forbidden", + }, + } + configuration_id = str(manifest.get("extension_configuration_guid") or "").lower() + if not is_guid_text(configuration_id): + return None, { + "schema": "onec_support_rules.v1", + "method": "metadata.support.decode", + "status": "source_unknown", + "base_id": base_id, + "layer_id": f"extension:{extension_guid}", + "diagnostics": { + "message": "The extension manifest does not contain a valid configuration GUID.", + "permission_inference": "forbidden", + }, + } + support_object_id = f"{configuration_id}.4" + entry = next( + ( + item + for item in (manifest.get("entries") or []) + if str(item.get("object_id") or "").lower() == support_object_id + ), + None, + ) + source = { + "kind": "live_sql", + "database": "", + "table": "ConfigCAS", + "root_cas_key": str(manifest.get("root_cas_key") or ""), + "configuration_id": configuration_id, + "extension": manifest.get("extension"), + "manifest_declared_count": manifest.get("declared_count"), + "manifest_entry_count": manifest.get("entry_count"), + "discovery": "extension_cas_manifest", + } + if not entry: + return {**source, "support_payload_present": False}, None + return { + **source, + "file_name": str(entry.get("cas_key") or "").lower(), + "support_object_id": support_object_id, + "support_payload_present": True, + }, None + + +def metadata_support_decode(payload: dict[str, Any]) -> dict[str, Any]: + method = "metadata.support.decode" + normalized = normalize_object_selector_aliases(payload, method) + if isinstance(normalized, dict) and normalized.get("status") == "invalid_argument": + return normalized + payload = normalized + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + argument_error = validate_optional_non_empty_string_arguments( + payload, + method, + ["file_name", "object_guid", "guid", "layer_id"], + ) + if argument_error: + return argument_error + layer_id = str(payload.get("layer_id") or "base").strip().lower() + if layer_id != "base" and not layer_id.startswith("extension:"): + return invalid_argument(method, "layer_id", "layer_id must be base or extension:.") + if layer_id.startswith("extension:") and not is_guid_text(layer_id.split(":", 1)[1]): + return invalid_argument(method, "layer_id", "The extension layer_id suffix must be a GUID.") + object_guid = str(payload.get("object_guid") or payload.get("guid") or "").strip().lower() + if object_guid and not is_guid_text(object_guid): + return invalid_argument(method, "object_guid", "object_guid must be a GUID string.") + file_name = str(payload.get("file_name") or "").strip() + source_discovery = "caller_verified" + discovered_source: dict[str, Any] | None = None + timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) + if timeout_error: + return timeout_error + resolved_object: dict[str, Any] | None = None + if not object_guid and payload.get("name"): + if layer_id == "base": + resolved = get_object( + payload.get("kind"), + str(payload.get("name") or ""), + base_id=base_id_or_error, + include_semantic=False, + timeout_seconds=int(timeout_seconds or 30), + ) + if resolved.get("status") != "ok": + result = dict(resolved) + result["method"] = method + return result + object_card = resolved.get("object") if isinstance(resolved.get("object"), dict) else resolved + else: + found = extension_objects_find( + { + "base_id": base_id_or_error, + "extension_guid": layer_id.split(":", 1)[1], + "query": payload.get("name"), + "kind": payload.get("kind"), + "limit": 20, + "scan_limit": 5000, + } + ) + if found.get("status") not in {"ok", "partial"}: + result = dict(found) + result["method"] = method + return result + wanted_name = normalize_exact(payload.get("name")) + object_card = next( + ( + item + for item in found.get("objects") or [] + if isinstance(item, dict) + and normalize_exact(item.get("name")) == wanted_name + and ( + not payload.get("kind") + or canonical_kind(str(item.get("kind") or "")) == canonical_kind(str(payload.get("kind") or "")) + ) + ), + None, + ) + if not object_card: + return { + "schema": "onec_support_rules.v1", + "method": method, + "status": "not_found", + "error": "object_not_found", + "base_id": base_id_or_error, + "query": { + "kind": payload.get("kind"), + "name": payload.get("name"), + "layer_id": layer_id, + }, + "diagnostics": {"message": "The selected extension object was not found by its 1C name."}, + } + object_guid = str((object_card or {}).get("guid") or "").strip().lower() + if not is_guid_text(object_guid): + return { + "schema": "onec_support_rules.v1", + "method": method, + "status": "source_missing", + "error": "object_guid_unresolved", + "base_id": base_id_or_error, + "query": {"kind": payload.get("kind"), "name": payload.get("name"), "layer_id": layer_id}, + "diagnostics": {"message": "The selected object was resolved by name, but its metadata GUID is unavailable."}, + } + resolved_object = public_metadata_row(object_card) + if not file_name: + if layer_id == "base": + discovered_source, discovery_error = discover_base_support_source( + base_id_or_error, timeout_seconds=int(timeout_seconds or 30) + ) + else: + discovered_source, discovery_error = discover_extension_support_source( + base_id_or_error, + layer_id.split(":", 1)[1], + timeout_seconds=int(timeout_seconds or 30), + ) + if discovery_error: + return discovery_error + if discovered_source and discovered_source.get("support_payload_present") is False: + return { + "schema": "onec_support_rules.v1", + "method": method, + "status": "ok", + "base_id": base_id_or_error, + "layer_id": layer_id, + "source": discovered_source, + "format_marker": None, + "supplier_count": 0, + "suppliers": [], + "query": { + "object_guid": object_guid or None, + **({"object": resolved_object} if resolved_object else {}), + }, + "object_support": { + "object_guid": object_guid or None, + "status": "not_supported", + "edit_allowed_by_support": True, + "evidence": "support_payload_absent_from_complete_extension_manifest", + }, + "matches": [], + "counts": {"suppliers": 0, "matches": 0}, + "diagnostics": { + "source_discovery": "extension_cas_manifest", + "repository_lock_evaluated": False, + }, + } + file_name = str((discovered_source or {}).get("file_name") or "") + source_discovery = str((discovered_source or {}).get("discovery") or "") + if Path(file_name).name != file_name: + return invalid_argument(method, "file_name", "file_name must be a safe storage file name.") + table_payload = dict(payload) + if discovered_source: + table_payload["table"] = discovered_source["table"] + table_or_error = storage_table(table_payload, method) + if isinstance(table_or_error, dict): + return table_or_error + data, config, source_error = read_storage_file_bytes( + base_id_or_error, + table_or_error, + file_name, + timeout_seconds=int(timeout_seconds or 30), + ) + if source_error: + return public_error_result(source_error, method=method) + try: + from parser.support_rules import parse_parent_configurations_bytes + + decoded = parse_parent_configurations_bytes(data or b"", source=file_name) + except ValueError as exc: + return { + "schema": "onec_support_rules.v1", + "method": method, + "status": "invalid_format", + "base_id": base_id_or_error, + "diagnostics": {"message": str(exc), "permission_inference": "forbidden"}, + } + suppliers = [] + matches = [] + for supplier in decoded["suppliers"]: + rules = list(supplier.rules) + suppliers.append({ + "configuration_guid": supplier.configuration_guid, + "general_mode_code": supplier.general_mode_code, + "general_mode": supplier.general_mode, + "version": supplier.version, + "producer": supplier.producer, + "name": supplier.name, + "declared_object_count": supplier.declared_object_count, + "parsed_object_count": len(rules), + }) + if object_guid: + matches.extend({**rule.to_dict(), "configuration_guid": supplier.configuration_guid} for rule in rules if rule.object_guid == object_guid) + object_support = None + if object_guid: + if not matches: + object_support = { + "object_guid": object_guid, + "status": "not_supported", + "edit_allowed_by_support": True, + "evidence": "absent_from_complete_parent_configurations", + } + elif any(int(match.get("rule_code", -1)) == 0 for match in matches): + object_support = { + "object_guid": object_guid, + "status": "not_editable", + "edit_allowed_by_support": False, + "evidence": "effective_supplier_rule", + } + elif any(int(match.get("rule_code", -1)) == 1 for match in matches): + object_support = { + "object_guid": object_guid, + "status": "editable_support_preserved", + "edit_allowed_by_support": True, + "evidence": "effective_supplier_rule", + } + else: + object_support = { + "object_guid": object_guid, + "status": "not_supported", + "edit_allowed_by_support": True, + "evidence": "effective_supplier_rule", + } + return { + "schema": "onec_support_rules.v1", + "method": method, + "status": "ok", + "base_id": base_id_or_error, + "layer_id": layer_id, + "source": discovered_source or {"kind": "live_sql", "database": (config or {}).get("database"), "table": table_or_error, "file_name": file_name}, + "format_marker": decoded["format_marker"], + "supplier_count": decoded["supplier_count"], + "suppliers": suppliers, + "query": { + "object_guid": object_guid or None, + **({"object": resolved_object} if resolved_object else {}), + }, + "object_support": object_support, + "matches": matches, + "counts": {"suppliers": len(suppliers), "matches": len(matches)}, + "diagnostics": { + "source_discovery": source_discovery, + "missing_match_semantics": "not_supported" if object_guid and not matches else None, + "repository_lock_evaluated": False, + }, + } + + +REPOSITORY_SQL_STATE_TABLES = ("Params", "Config", "ConfigSave", "ConfigCAS", "ConfigCASSave") + + +def repository_sql_state_snapshot(payload: dict[str, Any]) -> dict[str, Any]: + method = "repository.sql_state.snapshot" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + diagnostic_error = require_diagnostic_mode(payload, method) + if diagnostic_error: + return diagnostic_error + raw_tables = payload.get("tables", ["Params"]) + if not isinstance(raw_tables, list) or not raw_tables or any(not isinstance(item, str) for item in raw_tables): + return invalid_argument(method, "tables", "tables must be a non-empty JSON array of storage table names.") + tables = list(dict.fromkeys(str(item).strip() for item in raw_tables)) + unsupported = [item for item in tables if item not in REPOSITORY_SQL_STATE_TABLES] + if unsupported: + return invalid_argument( + method, + "tables", + "Unsupported SQL state table.", + allowed_values=list(REPOSITORY_SQL_STATE_TABLES), + ) + include_extensions, include_extensions_error = strict_bool_argument( + payload, "include_extensions", method=method, default=True + ) + if include_extensions_error: + return include_extensions_error + timeout_seconds, timeout_error = parse_int_argument( + payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=600 + ) + if timeout_error: + return timeout_error + conn, config, connection_error = connect_live_sql( + base_id_or_error, method, timeout_seconds=int(timeout_seconds or 120) + ) + if connection_error: + return connection_error + records: list[dict[str, Any]] = [] + extensions: list[dict[str, Any]] = [] + started = time.time() + try: + with conn: + with conn.cursor(as_dict=True) as cursor: + for table in tables: + cursor.execute( + f"SELECT FileName, PartNo, BinaryData FROM dbo.[{table}] ORDER BY FileName, PartNo" + ) + current_name: str | None = None + current_parts: list[dict[str, Any]] = [] + current_hash = hashlib.sha1() + current_bytes = 0 + + def finish_record() -> None: + nonlocal current_name, current_parts, current_hash, current_bytes + if current_name is None: + return + records.append( + { + "area": "storage", + "table": table, + "file_name": current_name, + "part_count": len(current_parts), + "bytes": current_bytes, + "sha1": current_hash.hexdigest(), + "parts": current_parts, + } + ) + + for row in cursor: + file_name = str(row.get("FileName") or "") + if current_name is not None and file_name != current_name: + finish_record() + current_parts = [] + current_hash = hashlib.sha1() + current_bytes = 0 + current_name = file_name + value = row.get("BinaryData") + data = bytes(value) if isinstance(value, (bytes, bytearray, memoryview)) else b"" + part_no = jsonable(row.get("PartNo")) + current_hash.update(data) + current_bytes += len(data) + current_parts.append( + {"part_no": part_no, "bytes": len(data), "sha1": hashlib.sha1(data).hexdigest()} + ) + finish_record() + if include_extensions: + cursor.execute( + "SELECT [_IDRRef], [_ExtName], [_ExtensionOrder], [_ExtensionZippedInfo] " + "FROM dbo.[_ExtensionsInfo] ORDER BY [_ExtensionOrder], [_ExtName]" + ) + for row in cursor.fetchall(): + zipped = row.get("_ExtensionZippedInfo") + zipped_data = bytes(zipped) if isinstance(zipped, (bytes, bytearray, memoryview)) else b"" + extensions.append( + { + "area": "extensions_info", + "guid": dbnames_ext_guid_from_idrref(row.get("_IDRRef")), + "name": jsonable(row.get("_ExtName")), + "order": jsonable(row.get("_ExtensionOrder")), + "bytes": len(zipped_data), + "sha1": hashlib.sha1(zipped_data).hexdigest(), + "root_cas_key": extension_root_key_from_zipped_info(zipped_data), + } + ) + except Exception as exc: + return { + "schema": "onec_repository_sql_state_snapshot.v1", + "method": method, + "status": "error", + "base_id": base_id_or_error, + "diagnostics": {"message": str(exc), "native_lock_inference": "forbidden"}, + } + portable_snapshot = { + "schema": "onec_repository_sql_state_snapshot.v1", + "base_id": base_id_or_error, + "scope": {"tables": tables, "include_extensions": bool(include_extensions)}, + "records": records, + "extensions": extensions, + } + identity = json.dumps( + portable_snapshot, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + snapshot_id = hashlib.sha256(identity).hexdigest() + comparison_token = base64.urlsafe_b64encode(zlib.compress(identity, level=9)).decode("ascii") + return { + "schema": "onec_repository_sql_state_snapshot.v1", + "method": method, + "status": "ok", + "base_id": base_id_or_error, + "snapshot_id": snapshot_id, + "comparison_token": comparison_token, + "source": {"kind": "live_sql", "database": (config or {}).get("database")}, + "scope": {"tables": tables, "include_extensions": bool(include_extensions)}, + "records": records, + "extensions": extensions, + "counts": {"records": len(records), "extensions": len(extensions)}, + "duration_ms": int((time.time() - started) * 1000), + "diagnostics": { + "payload_returned": False, + "native_lock_inference": "forbidden_until_repeatable_differential_validation", + }, + } + + +def repository_sql_state_diff(payload: dict[str, Any]) -> dict[str, Any]: + method = "repository.sql_state.diff" + diagnostic_error = require_diagnostic_mode(payload, method) + if diagnostic_error: + return diagnostic_error + def token_snapshot(value: Any, argument: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + if value is None or value == "": + return None, None + if not isinstance(value, str): + return None, invalid_argument(method, argument, f"{argument} must be a comparison_token string.") + try: + decoded = zlib.decompress(base64.urlsafe_b64decode(value.encode("ascii"))) + snapshot = json.loads(decoded.decode("utf-8")) + except Exception: + return None, invalid_argument(method, argument, f"{argument} is not a valid comparison_token.") + if not isinstance(snapshot, dict): + return None, invalid_argument(method, argument, f"{argument} did not decode to a snapshot object.") + snapshot["snapshot_id"] = hashlib.sha256(decoded).hexdigest() + return snapshot, None + + before = payload.get("before") + after = payload.get("after") + if before is None and payload.get("before_token") is not None: + before, token_error = token_snapshot(payload.get("before_token"), "before_token") + if token_error: + return token_error + if after is None and payload.get("after_token") is not None: + after, token_error = token_snapshot(payload.get("after_token"), "after_token") + if token_error: + return token_error + if not isinstance(before, dict) or not isinstance(after, dict): + return invalid_argument(method, "before/after", "before and after must be snapshot JSON objects.") + if before.get("schema") != "onec_repository_sql_state_snapshot.v1" or after.get("schema") != before.get("schema"): + return invalid_argument(method, "before/after", "Both inputs must be repository.sql_state.snapshot responses.") + if str(before.get("base_id") or "") != str(after.get("base_id") or ""): + return invalid_argument(method, "before/after", "Snapshots must belong to the same base_id.") + + def keyed(snapshot: dict[str, Any]) -> dict[str, dict[str, Any]]: + result: dict[str, dict[str, Any]] = {} + for item in snapshot.get("records") or []: + if isinstance(item, dict): + result[f"storage:{item.get('table')}:{item.get('file_name')}"] = item + for item in snapshot.get("extensions") or []: + if isinstance(item, dict): + result[f"extensions_info:{item.get('guid') or item.get('name')}"] = item + return result + + before_items = keyed(before) + after_items = keyed(after) + added = [] + removed = [] + changed = [] + for key in sorted(set(before_items) | set(after_items)): + old = before_items.get(key) + new = after_items.get(key) + if old is None: + added.append({"key": key, "after": new}) + elif new is None: + removed.append({"key": key, "before": old}) + elif old.get("sha1") != new.get("sha1") or old.get("bytes") != new.get("bytes"): + old_parts = {str(item.get("part_no")): item for item in old.get("parts") or [] if isinstance(item, dict)} + new_parts = {str(item.get("part_no")): item for item in new.get("parts") or [] if isinstance(item, dict)} + changed_parts = [ + part + for part in sorted(set(old_parts) | set(new_parts)) + if old_parts.get(part) != new_parts.get(part) + ] + changed.append( + { + "key": key, + "before": {key: old.get(key) for key in ("bytes", "sha1", "part_count") if key in old}, + "after": {key: new.get(key) for key in ("bytes", "sha1", "part_count") if key in new}, + "changed_parts": changed_parts, + } + ) + return { + "schema": "onec_repository_sql_state_diff.v1", + "method": method, + "status": "ok", + "base_id": before.get("base_id"), + "before_snapshot_id": before.get("snapshot_id"), + "after_snapshot_id": after.get("snapshot_id"), + "changed": bool(added or removed or changed), + "added": added, + "removed": removed, + "modified": changed, + "counts": {"added": len(added), "removed": len(removed), "modified": len(changed)}, + "diagnostics": {"native_lock_inference": "forbidden_until_repeatable_differential_validation"}, + } + + SAVED_STATE_SOURCE_BY_TARGET = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"} SAVED_STATE_TARGET_BY_SOURCE = {"Config": "ConfigSave", "ConfigCAS": "ConfigCASSave"} +SAVED_STATE_TABLE_BY_LAYER = {"base_saved_state": "ConfigSave", "extension_saved_state": "ConfigCASSave"} +SAVED_STATE_LAYER_BY_TABLE = {table: layer for layer, table in SAVED_STATE_TABLE_BY_LAYER.items()} +SAVED_STATE_ACTIVE_LAYER_BY_TABLE = {"ConfigSave": "base_configuration", "ConfigCASSave": "extension_configuration"} SAVED_STATE_COPY_COLUMNS = ("FileName", "Creation", "Modified", "Attributes", "DataSize", "BinaryData", "PartNo") @@ -15285,9 +17375,12 @@ def saved_state_prepare_file_names(payload: dict[str, Any], base_id: str, source limit=20, ) matches = [] + requested_name = str(payload.get("name") or payload.get("object_name") or "").strip() for row in rows: if extension_name and normalize(row.get("extension_name")) != normalize(extension_name) and str(row.get("extension_guid") or "").lower() != extension_name.lower(): continue + if requested_name and normalize(row.get("name")) != normalize(requested_name): + continue matches.append(extension_route_cache_row_to_match(base_id, row, include_storage=True, freshness={"status": "cache_hit_verified"})) if len(matches) != 1: return [], {"mode": "extension_route_cache", "matches": len(matches), "extension": extension_name}, { @@ -15432,12 +17525,155 @@ def apply_saved_state_prepare_copy( } +def public_saved_state_prepare_object(object_card: dict[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(object_card, dict): + return None + kind = object_card.get("kind") or object_card.get("object_type") + name = object_card.get("name") or object_card.get("object_name") + extension = object_card.get("extension") + result = { + **({"kind": kind} if kind else {}), + **({"name": name} if name else {}), + **({"synonym": object_card.get("synonym")} if object_card.get("synonym") else {}), + **({"extension": extension} if isinstance(extension, str) and extension else {}), + } + ref = object_selector_ref(str(kind or ""), str(name or "")) + if ref: + result["ref"] = ref + return result or None + + +def public_saved_state_prepare_call_payload(payload: dict[str, Any]) -> dict[str, Any]: + target_table = str(payload.get("target_table") or payload.get("table") or "").strip() + source_table = str(payload.get("source_table") or "").strip() + layer = str(payload.get("layer") or "").strip() + if layer not in SAVED_STATE_TABLE_BY_LAYER: + if target_table in SAVED_STATE_LAYER_BY_TABLE: + layer = SAVED_STATE_LAYER_BY_TABLE[target_table] + elif source_table in SAVED_STATE_TARGET_BY_SOURCE: + layer = SAVED_STATE_LAYER_BY_TABLE[SAVED_STATE_TARGET_BY_SOURCE[source_table]] + elif str(payload.get("extension") or "").strip(): + layer = "extension_saved_state" + else: + layer = "base_saved_state" + + ref = str(payload.get("ref") or "").strip() + kind = str(payload.get("kind") or payload.get("object_type") or "").strip() + name = str(payload.get("name") or payload.get("object_name") or "").strip() + if ref and not is_guid_text(ref): + ref_kind, ref_name = parse_object_query(None, ref) + kind = kind or str(ref_kind or "") + name = name or str(ref_name or "") + if not ref or is_guid_text(ref): + ref = object_selector_ref(kind, name) or "" + + result: dict[str, Any] = { + "base_id": payload.get("base_id"), + "layer": layer, + **({"ref": ref} if ref else {}), + **({"kind": canonical_kind(kind)} if kind else {}), + **({"name": name} if name else {}), + **({"extension": payload.get("extension")} if isinstance(payload.get("extension"), str) and payload.get("extension") else {}), + **({"query": payload.get("query")} if isinstance(payload.get("query"), str) and payload.get("query") else {}), + "mode": "plan", + } + if not any(result.get(key) for key in ("ref", "name", "query")): + result["selector_required"] = "Pass a public 1C ref or kind/name selector." + return result + + +def public_saved_state_prepare_embedded_result(result: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: + target_table = str(payload.get("target_table") or payload.get("table") or "").strip() + source_table = str(payload.get("source_table") or "").strip() + layer = str(payload.get("layer") or "").strip() + if target_table not in SAVED_STATE_SOURCE_BY_TARGET: + if layer in SAVED_STATE_TABLE_BY_LAYER: + target_table = SAVED_STATE_TABLE_BY_LAYER[layer] + elif source_table in SAVED_STATE_TARGET_BY_SOURCE: + target_table = SAVED_STATE_TARGET_BY_SOURCE[source_table] + elif str(payload.get("extension") or "").strip(): + target_table = "ConfigCASSave" + else: + target_table = "ConfigSave" + object_card = result.get("object") if isinstance(result.get("object"), dict) else None + return public_saved_state_prepare_result( + result, + base_id=str(payload.get("base_id") or result.get("base_id") or ""), + target_table=target_table, + object_card=object_card, + ) + + +def public_saved_state_prepare_result( + result: dict[str, Any], + *, + base_id: str, + target_table: str, + object_card: dict[str, Any] | None = None, +) -> dict[str, Any]: + layer = SAVED_STATE_LAYER_BY_TABLE[target_table] + raw_counts = result.get("counts") if isinstance(result.get("counts"), dict) else {} + apply_result = result.get("apply_result") if isinstance(result.get("apply_result"), dict) else {} + apply_counts = apply_result.get("counts") if isinstance(apply_result.get("counts"), dict) else {} + result_object = result.get("object") if isinstance(result.get("object"), dict) else None + object_public = public_saved_state_prepare_object(object_card or result_object) + public: dict[str, Any] = { + "schema": "onec_saved_state_prepare.v1", + "method": "metadata.saved_state.prepare", + "status": result.get("status") or "error", + "applied": bool(result.get("applied")), + "ready_to_copy": bool(result.get("ready_to_copy")), + "base_id": base_id, + "source": { + "kind": "active_configuration", + "layer": SAVED_STATE_ACTIVE_LAYER_BY_TABLE[target_table], + }, + "target": {"layer": layer}, + **({"object": object_public} if object_public else {}), + "counts": { + "selected_objects": 1 if object_public else 0, + "source_records": int(raw_counts.get("source_rows") or 0), + "existing_saved_records": int(raw_counts.get("target_rows") or 0), + "inserted_records": int(apply_counts.get("inserted_rows") or 0), + }, + "write_mode": { + "target": "saved_state", + "sql_write_performed": bool(result.get("applied")), + "requires_allow_flag": True, + }, + "freshness": { + "source": "saved_state", + "status": "live_verified", + "verified": True, + }, + "diagnostics": { + "note": "Storage coordinates, files, hashes, and row details are hidden unless include_storage=true.", + }, + } + if result.get("error"): + public["error"] = result.get("error") + verification = result.get("verification") if isinstance(result.get("verification"), dict) else None + if verification: + mismatched = verification.get("mismatched") if isinstance(verification.get("mismatched"), list) else [] + public["verification"] = { + "status": verification.get("status"), + "expected_records": verification.get("expected_rows"), + "actual_records": verification.get("actual_rows"), + "mismatched_records": len(mismatched), + } + return public + + def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.saved_state.prepare" base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error base_id = base_id_or_error + normalized_payload = normalize_object_selector_aliases(payload, method) + if normalized_payload.get("status") == "invalid_argument": + return normalized_payload + payload = normalized_payload timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) if timeout_error: return timeout_error @@ -15448,7 +17684,17 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: repository_error = repository_apply_gate(payload, method, mode) if repository_error: return repository_error - target_table = str(payload.get("target_table") or payload.get("table") or "").strip() + include_storage, include_storage_error = strict_include_storage(payload, method) + if include_storage_error: + return include_storage_error + include_storage = bool(include_storage) + layer = str(payload.get("layer") or "").strip() + if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: + return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) + requested_target_table = str(payload.get("target_table") or payload.get("table") or "").strip() + if layer and requested_target_table and requested_target_table != SAVED_STATE_TABLE_BY_LAYER[layer]: + return invalid_argument(method, "layer", "layer conflicts with target_table.") + target_table = requested_target_table or SAVED_STATE_TABLE_BY_LAYER.get(layer, "") source_table = str(payload.get("source_table") or "").strip() if not target_table: if source_table in SAVED_STATE_TARGET_BY_SOURCE: @@ -15459,6 +17705,8 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: target_table = "ConfigSave" if target_table not in SAVED_STATE_SOURCE_BY_TARGET: return invalid_argument(method, "target_table", "target_table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) + if str(payload.get("extension") or "").strip() and target_table != "ConfigCASSave": + return invalid_argument(method, "layer", "Extension object preparation requires extension_saved_state.", allowed_values=["extension_saved_state"]) expected_source_table = SAVED_STATE_SOURCE_BY_TARGET[target_table] if source_table and source_table != expected_source_table: return invalid_argument(method, "source_table", f"source_table must be {expected_source_table} for {target_table}.", allowed_values=[expected_source_table]) @@ -15466,10 +17714,16 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: file_names, object_card, file_error = saved_state_prepare_file_names(payload, base_id, source_table, timeout_seconds) if file_error: - return file_error + return file_error if include_storage else public_saved_state_prepare_result( + file_error, + base_id=base_id, + target_table=target_table, + object_card=object_card, + ) if not file_names: - return { + no_rows_result = { "schema": "onec_saved_state_prepare.v1", + "method": method, "status": "blocked_no_active_source_rows", "applied": False, "base_id": base_id, @@ -15478,12 +17732,28 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: "object": object_card, "counts": {"file_names": 0}, } + return no_rows_result if include_storage else public_saved_state_prepare_result( + no_rows_result, + base_id=base_id, + target_table=target_table, + object_card=object_card, + ) source_rows, config, source_error = saved_state_copy_row_details(base_id, source_table, file_names, timeout_seconds=timeout_seconds) if source_error: - return source_error + return source_error if include_storage else public_saved_state_prepare_result( + source_error, + base_id=base_id, + target_table=target_table, + object_card=object_card, + ) target_rows, _target_config, target_error = saved_state_copy_row_details(base_id, target_table, file_names, timeout_seconds=timeout_seconds) if target_error: - return target_error + return target_error if include_storage else public_saved_state_prepare_result( + target_error, + base_id=base_id, + target_table=target_table, + object_card=object_card, + ) source_rows = source_rows or [] target_rows = target_rows or [] public_source_rows = [saved_state_row_public(row) for row in source_rows] @@ -15509,7 +17779,12 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: "write_mode": {"sql_write_performed": False, "requires_allow_flag": True}, } if mode == "plan" or status != "plan_ready": - return result + return result if include_storage else public_saved_state_prepare_result( + result, + base_id=base_id, + target_table=target_table, + object_card=object_card, + ) allow_prepare, allow_error = strict_bool_argument(payload, "allow_sql_saved_state_prepare", method=method, default=False) if allow_error: return allow_error @@ -15528,12 +17803,22 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: result["write_mode"]["sql_write_performed"] = bool(apply_result.get("applied")) result["status"] = apply_result.get("status") or "error" if mode == "apply" or not result["applied"]: - return result + return result if include_storage else public_saved_state_prepare_result( + result, + base_id=base_id, + target_table=target_table, + object_card=object_card, + ) verify_rows, _verify_config, verify_error = saved_state_copy_row_details(base_id, target_table, file_names, timeout_seconds=timeout_seconds) if verify_error: result["status"] = "verify_error" result["verify_error"] = verify_error - return result + return result if include_storage else public_saved_state_prepare_result( + result, + base_id=base_id, + target_table=target_table, + object_card=object_card, + ) expected = {(row.get("FileName"), row.get("PartNo")): saved_state_row_public(row) for row in source_rows} actual = {(row.get("FileName"), row.get("PartNo")): saved_state_row_public(row) for row in (verify_rows or [])} mismatched = [ @@ -15548,11 +17833,101 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: "mismatched": mismatched, } result["status"] = "verified" if result["verification"]["status"] == "ok" else "verification_failed" - return result + return result if include_storage else public_saved_state_prepare_result( + result, + base_id=base_id, + target_table=target_table, + object_card=object_card, + ) + + +def saved_state_diff_public_module_target( + payload: dict[str, Any], + base_id: str, + target_table: str, + timeout_seconds: int, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + source_table = SAVED_STATE_SOURCE_BY_TARGET[target_table] + last_result: dict[str, Any] | None = None + for table in (target_table, source_table): + for refresh_cache in (False, True): + module_result = read_module( + { + **payload, + "base_id": base_id, + "table": table, + "include_storage": True, + "include_text": False, + "mode": "summary", + "max_chars": 1, + "refresh_cache": refresh_cache, + "timeout_seconds": timeout_seconds, + } + ) + last_result = module_result + if module_result.get("status") != "ok": + continue + source = module_result.get("source") if isinstance(module_result.get("source"), dict) else {} + module_id = str(module_result.get("module_id") or "").strip() + module_table, module_file_name, stream_index = parse_module_id(module_id) + file_name = str(source.get("file_name") or module_file_name or "").strip() + if not file_name or Path(file_name).name != file_name: + continue + owner = module_result.get("owner") if isinstance(module_result.get("owner"), dict) else {} + module = module_result.get("module") if isinstance(module_result.get("module"), dict) else {} + module_ordinal = first_non_empty_arg( + payload, + "module_ordinal", + "module_index", + "module_number", + default=module.get("module_ordinal") or 1, + ) + public_ref = str(payload.get("ref") or object_selector_ref(owner.get("kind"), owner.get("name")) or "").strip() + selector = { + **({"ref": public_ref} if public_ref else {}), + **({"kind": owner.get("kind")} if owner.get("kind") else {}), + **({"name": owner.get("name")} if owner.get("name") else {}), + **({"module_ordinal": module_ordinal} if module else {}), + **({"extension": payload.get("extension")} if payload.get("extension") else {}), + } + public_role = public_module_role( + owner_kind=str(owner.get("kind") or ""), + suffix=module_suffix_from_module_id(module_id), + ordinal=int(module_ordinal or 1), + current_name=str(owner.get("name") or "") if canonical_kind(str(owner.get("kind") or "")) == "CommonModule" else None, + ) + target_module_ref = f"{target_table}:{file_name}" + if stream_index is not None: + target_module_ref += f"#stream:{stream_index}" + return { + "file_name": file_name, + "module_ref": target_module_ref, + "stream_index": stream_index, + "resolved_from_table": module_table or table, + "cache_refresh_used": refresh_cache, + "selector": selector, + "object": {key: owner.get(key) for key in ("kind", "name", "synonym", "guid") if owner.get(key) is not None}, + "module": { + **{key: public_role.get(key) for key in ("kind", "name") if public_role.get(key) is not None}, + **({"module_ordinal": module_ordinal} if module else {}), + }, + }, None + return None, { + "schema": "onec_saved_state_diff.v1", + "method": SAVED_STATE_DIFF_METHOD, + "status": str((last_result or {}).get("status") or "not_found"), + "error": str((last_result or {}).get("error") or "module_not_found"), + "base_id": base_id, + "diagnostics": (last_result or {}).get("diagnostics") + or {"message": "The named 1C object module was not resolved in saved or active metadata."}, + } def metadata_saved_state_diff(payload: dict[str, Any]) -> dict[str, Any]: method = SAVED_STATE_DIFF_METHOD + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error @@ -15569,6 +17944,9 @@ def metadata_saved_state_diff(payload: dict[str, Any]) -> dict[str, Any]: include_payload_diff, include_payload_diff_error = strict_bool_argument(payload, "include_payload_diff", method=method, default=False) if include_payload_diff_error: return include_payload_diff_error + include_storage, include_storage_error = strict_include_storage(payload, method) + if include_storage_error: + return include_storage_error for argument, default in (("include_text_diff", True), ("include_tree_diff", True), ("include_evidence", False)): _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) if bool_error: @@ -15581,16 +17959,43 @@ def metadata_saved_state_diff(payload: dict[str, Any]) -> dict[str, Any]: if not module_table or not module_file_name: return invalid_argument(method, "module_ref", "Use module_ref in the form
:[#stream:].") table = str(payload.get("table") or payload.get("target_table") or module_table or "").strip() + public_selector_used = not module_ref and not str(payload.get("file_name") or "").strip() and has_object_selector(payload) + if not table and public_selector_used: + table = "ConfigCASSave" if str(payload.get("extension") or "").strip() else "ConfigSave" file_name = str(payload.get("file_name") or module_file_name or "").strip() if table not in SAVED_STATE_SOURCE_BY_TARGET: return invalid_argument(method, "table", "table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) + resolved_target: dict[str, Any] | None = None + if public_selector_used: + resolved_target, resolution_error = saved_state_diff_public_module_target( + payload, + base_id, + table, + int(timeout_seconds or 30), + ) + if resolution_error: + return resolution_error + file_name = str((resolved_target or {}).get("file_name") or "").strip() + module_ref = str((resolved_target or {}).get("module_ref") or "").strip() if not file_name or Path(file_name).name != file_name: - return invalid_argument(method, "file_name", "Pass a safe saved-state FileName or module_ref.") + return invalid_argument( + method, + "selector", + "Pass a 1C object selector plus module_ordinal, or a generated module_ref/saved-state file_name.", + ) source_table = str(payload.get("source_table") or SAVED_STATE_SOURCE_BY_TARGET[table]).strip() if source_table != SAVED_STATE_SOURCE_BY_TARGET[table]: return invalid_argument(method, "source_table", f"source_table must be {SAVED_STATE_SOURCE_BY_TARGET[table]} for {table}.") - target = {"table": table, "file_name": file_name, **({"module_ref": module_ref} if module_ref else {})} + storage_target = {"table": table, "file_name": file_name, **({"module_ref": module_ref} if module_ref else {})} + target = storage_target + if public_selector_used: + target = { + "selector": (resolved_target or {}).get("selector") or {}, + "object": (resolved_target or {}).get("object") or {}, + "module": (resolved_target or {}).get("module") or {}, + **({"storage": storage_target} if include_storage else {}), + } active_source = {"table": source_table, "file_name": file_name} saved_bytes, config, saved_error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=int(timeout_seconds or 30)) if saved_error: @@ -15602,16 +18007,30 @@ def metadata_saved_state_diff(payload: dict[str, Any]) -> dict[str, Any]: "error": "saved_state_not_found" if status == "not_found" else saved_error.get("error", "source_error"), "base_id": base_id, "target": target, - "source": active_source, + "source": ( + {"kind": "live_metadata", "active": {"state": "active"}} + if public_selector_used and not include_storage + else active_source + ), "current_state": {"source": "active", "activation_state": "active"}, "needs_prepare": status == "not_found", - "prepare_payload": { - "method": "metadata.saved_state.prepare", - "base_id": base_id, - "target_table": table, - "file_name": file_name, - "mode": "plan", - }, + "prepare_payload": ( + { + "method": "metadata.saved_state.prepare", + "base_id": base_id, + "layer": SAVED_STATE_LAYER_BY_TABLE[table], + **((resolved_target or {}).get("selector") or {}), + "mode": "plan", + } + if public_selector_used + else { + "method": "metadata.saved_state.prepare", + "base_id": base_id, + "target_table": table, + "file_name": file_name, + "mode": "plan", + } + ), "diagnostics": saved_error.get("diagnostics") or {"message": "Saved-state target was not found."}, } active_bytes, active_config, active_error = read_storage_file_bytes(base_id, source_table, file_name, timeout_seconds=int(timeout_seconds or 30)) @@ -15624,7 +18043,11 @@ def metadata_saved_state_diff(payload: dict[str, Any]) -> dict[str, Any]: "error": "active_source_not_found" if status == "not_found" else active_error.get("error", "source_error"), "base_id": base_id, "target": target, - "source": active_source, + "source": ( + {"kind": "live_metadata", "active": {"state": "active"}} + if public_selector_used and not include_storage + else active_source + ), "current_state": {"source": "saved_state", "activation_state": "not_activated"}, "needs_prepare": False, "diagnostics": active_error.get("diagnostics") or {"message": "Active source payload was not found."}, @@ -15652,7 +18075,11 @@ def metadata_saved_state_diff(payload: dict[str, Any]) -> dict[str, Any]: "error": diff.get("error", "payload_diff_failed"), "base_id": base_id, "target": target, - "source": active_source, + "source": ( + {"kind": "live_metadata", "active": {"state": "active"}, "saved": {"state": "saved"}} + if public_selector_used and not include_storage + else active_source + ), "needs_prepare": False, "diagnostics": diff.get("diagnostics") or diff, } @@ -15661,13 +18088,33 @@ def metadata_saved_state_diff(payload: dict[str, Any]) -> dict[str, Any]: "method": method, "status": diff.get("status"), "base_id": base_id, - "source": { - "kind": "live_sql", - "database": (config or active_config or {}).get("database"), - "active": active_source, - "saved": {"table": table, "file_name": file_name}, - }, + "source": ( + { + "kind": "live_metadata", + "active": {"state": "active"}, + "saved": {"state": "saved"}, + **( + { + "storage": { + "database": (config or active_config or {}).get("database"), + "active": active_source, + "saved": {"table": table, "file_name": file_name}, + } + } + if include_storage + else {} + ), + } + if public_selector_used + else { + "kind": "live_sql", + "database": (config or active_config or {}).get("database"), + "active": active_source, + "saved": {"table": table, "file_name": file_name}, + } + ), "target": target, + **({"resolved_by": "public_object_module_selector"} if public_selector_used else {}), "current_state": { "source": "saved_state" if diff.get("status") == "changed" else "both", "activation_state": "not_activated" if diff.get("status") == "changed" else "same_as_active", @@ -15765,9 +18212,20 @@ def metadata_saved_state_status(payload: dict[str, Any]) -> dict[str, Any]: include_unchanged, include_unchanged_error = strict_bool_argument(payload, "include_unchanged", method=method, default=True) if include_unchanged_error: return include_unchanged_error - table = str(payload.get("table") or payload.get("target_table") or "ConfigSave").strip() + include_storage, include_storage_error = strict_include_storage(payload, method) + if include_storage_error: + return include_storage_error + include_storage = bool(include_storage) + layer = str(payload.get("layer") or "").strip() + if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: + return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) + requested_table = str(payload.get("table") or payload.get("target_table") or "").strip() + if layer and requested_table and requested_table != SAVED_STATE_TABLE_BY_LAYER[layer]: + return invalid_argument(method, "layer", "layer conflicts with table.") + table = requested_table or SAVED_STATE_TABLE_BY_LAYER.get(layer) or "ConfigSave" if table not in SAVED_STATE_SOURCE_BY_TARGET: return invalid_argument(method, "table", "table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) + layer = SAVED_STATE_LAYER_BY_TABLE[table] prefix = str(payload.get("prefix") or "").strip() if prefix and Path(prefix).name != prefix: return invalid_argument(method, "prefix", "prefix must be a safe FileName prefix.") @@ -15836,7 +18294,7 @@ def metadata_saved_state_status(payload: dict[str, Any]) -> dict[str, Any]: } ) status = "empty" if not saved_public else ("changed" if counts["changed_files"] or counts["saved_only_files"] else "unchanged") - return { + storage_result = { "schema": "onec_saved_state_status.v1", "method": method, "status": status, @@ -15857,6 +18315,43 @@ def metadata_saved_state_status(payload: dict[str, Any]) -> dict[str, Any]: "verified_against_sql": True, }, } + if include_storage: + return storage_result + return { + "schema": "onec_saved_state_status.v1", + "method": method, + "status": status, + "base_id": base_id, + "source": {"kind": "saved_state"}, + "query": { + "layer": layer, + "limit": int(limit or 500), + "include_storage": False, + }, + "layer": { + "name": layer, + "active_source": SAVED_STATE_ACTIVE_LAYER_BY_TABLE[table], + }, + "current_state": storage_result["current_state"], + "counts": { + "saved_records": counts["saved_rows"], + "saved_objects": counts["saved_files"], + "active_records": counts["active_rows"], + "changed_objects": counts["changed_files"], + "unchanged_objects": counts["unchanged_files"], + "saved_only_objects": counts["saved_only_files"], + "changed_parts": counts["changed_rows"], + }, + "freshness": { + "source": "saved_state", + "status": "live_verified", + "verified": True, + }, + "diagnostics": { + "note": "Storage coordinates, file names, hashes, and diff selectors are hidden unless include_storage=true.", + "changes_method": SAVED_STATE_CHANGES_LIST_METHOD, + }, + } def metadata_saved_state_change_context( @@ -15878,19 +18373,39 @@ def metadata_saved_state_change_context( form_row = None if isinstance(form_row, dict): form = form_row.get("form") if isinstance(form_row.get("form"), dict) else {} + owner = form_row.get("owner") if isinstance(form_row.get("owner"), dict) else {} identity = form.get("identity") if isinstance(form.get("identity"), dict) else {} name = form_row.get("name") or form.get("name") or identity.get("name") synonym = form_row.get("synonym") or form.get("synonym") or identity.get("synonym") - return { - "kind": "form", - "presentation": ".".join(part for part in ["Форма", str(name or "")] if part), - "form": { - "name": name, - "synonym": synonym, - "guid": form.get("guid") or identity.get("guid"), - }, - "source": {"method": SAVED_STATE_FORMS_SEARCH_METHOD, "status": "resolved_by_file"}, - } + if not owner.get("name") and name: + related_owner = saved_state_related_descriptor_identity( + base_id=base_id, + table=table, + file_name=file_name, + timeout_seconds=timeout_seconds, + ) + if related_owner and related_owner.get("name"): + owner = { + "status": "resolved", + "kind": related_owner.get("kind") or "Catalog", + "name": related_owner.get("name"), + "synonym": related_owner.get("synonym"), + "guid": related_owner.get("guid"), + "source": "saved_state_descriptor", + } + owner_name = str(owner.get("name") or "").strip() + if name or owner_name: + return { + "kind": "form", + "presentation": ".".join(part for part in [owner_name, str(name or ""), "Форма"] if part), + **({"owner": owner} if owner else {}), + "form": { + "name": name, + "synonym": synonym, + "guid": form.get("guid") or identity.get("guid"), + }, + "source": {"method": SAVED_STATE_FORMS_SEARCH_METHOD, "status": "resolved_by_file"}, + } try: module_row = saved_state_module_search_row( base_id=base_id, @@ -15907,16 +18422,18 @@ def metadata_saved_state_change_context( form = module_row.get("form") if isinstance(module_row.get("form"), dict) else None module = module_row.get("module") if isinstance(module_row.get("module"), dict) else {} streams = module_row.get("streams") if isinstance(module_row.get("streams"), list) else [] + if not str((owner or {}).get("name") or "").strip() and not str((form or {}).get("name") or "").strip(): + return None return { "kind": "module", "presentation": module_row.get("display_name") or module_row.get("qualified_name") or module.get("name"), **({"owner": owner} if owner else {}), **({"form": form} if form else {}), - "module": module, - "streams": [ - { - "stream_index": stream.get("stream_index"), - "module_ref": stream.get("module_ref"), + "module": module, + "streams": [ + { + "stream_index": stream.get("stream_index"), + "module_ref": stream.get("module_ref"), "text_sha1": stream.get("text_sha1"), "write_plan_target": stream.get("write_plan_target"), } @@ -15928,6 +18445,194 @@ def metadata_saved_state_change_context( return None +def public_saved_state_change_context(context: dict[str, Any] | None) -> dict[str, Any] | None: + if not isinstance(context, dict): + return None + owner = context.get("owner") if isinstance(context.get("owner"), dict) else {} + form = context.get("form") if isinstance(context.get("form"), dict) else {} + module = context.get("module") if isinstance(context.get("module"), dict) else {} + owner_public = { + key: value + for key, value in owner.items() + if key in {"status", "kind", "name", "synonym", "source"} and value is not None + } + owner_ref = object_selector_ref(owner_public.get("kind"), owner_public.get("name")) + if owner_ref: + owner_public["ref"] = owner_ref + form_public = { + key: value + for key, value in form.items() + if key in {"name", "synonym", "source"} and value is not None + } + module_public = { + key: value + for key, value in module.items() + if key in {"kind", "name"} and value is not None + } + presentation = str(context.get("presentation") or "").strip() + selector = { + **({"ref": owner_ref} if owner_ref else {}), + **({"form": form_public.get("name")} if form_public.get("name") else {}), + **({"module": module_public.get("name")} if module_public.get("name") else {}), + **({"qualified_name": presentation} if presentation else {}), + } + source = context.get("source") if isinstance(context.get("source"), dict) else {} + return { + "kind": context.get("kind") or ("module" if module_public else ("form" if form_public else "object")), + **({"presentation": presentation} if presentation else {}), + **({"owner": owner_public} if owner_public else {}), + **({"form": form_public} if form_public else {}), + **({"module": module_public} if module_public else {}), + **({"selector": selector} if selector else {}), + **( + { + "resolution": { + key: value + for key, value in source.items() + if key in {"method", "status"} and value is not None + } + } + if source + else {} + ), + } + + +def public_saved_state_freshness(freshness: dict[str, Any] | None) -> dict[str, Any]: + raw = freshness if isinstance(freshness, dict) else {} + raw_status = str(raw.get("status") or "").strip() + status = raw_status.replace("live_sql", "live") if raw_status else "unknown" + verified = bool(raw.get("verified") or raw.get("verified_against_sql") or raw_status == "live_sql_verified") + return { + "source": "saved_state", + "status": status, + "verified": verified, + } + + +def public_saved_state_layer_counts(counts: dict[str, Any] | None) -> dict[str, Any]: + raw = counts if isinstance(counts, dict) else {} + return { + "saved_records": raw.get("saved_rows", 0), + "saved_objects": raw.get("saved_files", 0), + "active_records": raw.get("active_rows", 0), + "changed": raw.get("changed_files", 0), + "unchanged": raw.get("unchanged_files", 0), + "saved_only": raw.get("saved_only_files", 0), + "changed_parts": raw.get("changed_rows", 0), + } + + +def public_saved_state_change_item(item: dict[str, Any]) -> dict[str, Any]: + table = str(item.get("table") or "") + layer = "base_saved_state" if table == "ConfigSave" else "extension_saved_state" + context = public_saved_state_change_context(item.get("context")) + return { + "layer": layer, + "status": item.get("status"), + "changed_parts": item.get("changed_parts"), + **({"context": context} if context else {}), + **({"presentation": context.get("presentation")} if context and context.get("presentation") else {}), + **({"selector": context.get("selector")} if context and context.get("selector") else {}), + **( + { + "diagnostics": { + "status": "context_unresolved", + "message": "Saved-state change is counted, but its 1C object/form/module name was not resolved within context_limit.", + } + } + if not context + else {} + ), + } + + +def public_saved_state_change_group(group: dict[str, Any], index: int) -> dict[str, Any]: + unresolved = str(group.get("kind") or "") == "file" + context = None if unresolved else public_saved_state_change_context(group) + presentation = str((context or {}).get("presentation") or "").strip() + public_files = [ + { + "layer": "base_saved_state" if str(item.get("table") or "") == "ConfigSave" else "extension_saved_state", + "status": item.get("status"), + "changed_parts": item.get("changed_parts"), + } + for item in group.get("files") or [] + if isinstance(item, dict) + ] + raw_counts = group.get("counts") if isinstance(group.get("counts"), dict) else {} + public_counts = { + "changes": raw_counts.get("files", 0), + "changed": raw_counts.get("changed_files", 0), + "saved_only": raw_counts.get("saved_only_files", 0), + "unchanged": raw_counts.get("unchanged_files", 0), + } + return { + "key": f"unresolved_change:{index}" if unresolved else f"{str(group.get('kind') or 'change')}:{presentation or index}", + "kind": "unresolved" if unresolved else group.get("kind"), + **({"presentation": presentation} if presentation else {}), + **({"owner": context.get("owner")} if context and context.get("owner") else {}), + **({"form": context.get("form")} if context and context.get("form") else {}), + **({"module": context.get("module")} if context and context.get("module") else {}), + **({"selector": context.get("selector")} if context and context.get("selector") else {}), + "counts": public_counts, + "changes": public_files, + **( + { + "diagnostics": { + "status": "context_unresolved", + "message": "The saved-state group has no resolved 1C object/form/module name.", + } + } + if unresolved + else {} + ), + } + + +def saved_state_change_family_key(file_name: Any) -> str: + return re.sub(r"\.\d+$", "", str(file_name or "").strip()) + + +def enrich_saved_state_change_context_families(files: list[dict[str, Any]]) -> None: + named_forms: dict[tuple[str, str], dict[str, Any]] = {} + named_owners: dict[tuple[str, str], dict[str, Any]] = {} + for item in files: + context = item.get("context") if isinstance(item.get("context"), dict) else {} + key = (str(item.get("table") or ""), saved_state_change_family_key(item.get("file_name"))) + form = context.get("form") if isinstance(context.get("form"), dict) else {} + owner = context.get("owner") if isinstance(context.get("owner"), dict) else {} + if key[1] and form.get("name"): + named_forms[key] = form + if key[1] and owner.get("name"): + named_owners[key] = owner + for item in files: + context = item.get("context") if isinstance(item.get("context"), dict) else None + if not context: + continue + key = (str(item.get("table") or ""), saved_state_change_family_key(item.get("file_name"))) + form = context.get("form") if isinstance(context.get("form"), dict) else {} + owner = context.get("owner") if isinstance(context.get("owner"), dict) else {} + module = context.get("module") if isinstance(context.get("module"), dict) else {} + context_changed = False + if not form.get("name") and key in named_forms: + form = named_forms[key] + context["form"] = form + context_changed = True + if not owner.get("name") and key in named_owners: + owner = named_owners[key] + context["owner"] = owner + context_changed = True + if context_changed and module and (owner.get("name") or form.get("name")): + qualified_name = public_code_qualified_name(owner=owner, form=form, module=module) + if qualified_name: + context["presentation"] = qualified_name + elif context_changed and context.get("kind") == "form" and (owner.get("name") or form.get("name")): + context["presentation"] = ".".join( + part for part in [str(owner.get("name") or ""), str(form.get("name") or ""), "Форма"] if part + ) + + def saved_state_change_group_identity(item: dict[str, Any]) -> tuple[str, dict[str, Any]]: context = item.get("context") if isinstance(item.get("context"), dict) else {} table = str(item.get("table") or "") @@ -16143,10 +18848,20 @@ def metadata_saved_state_changes_list(payload: dict[str, Any]) -> dict[str, Any] group_by_context, group_by_context_error = strict_bool_argument(payload, "group_by_context", method=method, default=False) if group_by_context_error: return group_by_context_error + include_storage, include_storage_error = strict_include_storage(payload, method) + if include_storage_error: + return include_storage_error + include_storage = bool(include_storage) context_limit, context_limit_error = parse_int_argument(payload, "context_limit", method=method, default=50, minimum=0, maximum=500) if context_limit_error: return context_limit_error - table = str(payload.get("table") or payload.get("target_table") or "").strip() + layer = str(payload.get("layer") or "").strip() + if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: + return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) + requested_table = str(payload.get("table") or payload.get("target_table") or "").strip() + if layer and requested_table and requested_table != SAVED_STATE_TABLE_BY_LAYER[layer]: + return invalid_argument(method, "layer", "layer conflicts with table.") + table = requested_table or SAVED_STATE_TABLE_BY_LAYER.get(layer, "") tables = [table] if table else ["ConfigSave", "ConfigCASSave"] if any(item not in SAVED_STATE_SOURCE_BY_TARGET for item in tables): return invalid_argument(method, "table", "table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) @@ -16174,6 +18889,7 @@ def metadata_saved_state_changes_list(payload: dict[str, Any]) -> dict[str, Any] "limit": int(limit or 500), "include_files": True, "include_unchanged": bool(include_unchanged), + "include_storage": True, "timeout_seconds": int(timeout_seconds or 30), } ) @@ -16205,7 +18921,7 @@ def metadata_saved_state_changes_list(payload: dict[str, Any]) -> dict[str, Any] elif item_status == "unchanged": counts["unchanged_files"] += 1 files.append({"table": current_table, **item}) - context_enrichment = bool(include_context or group_by_context) + context_enrichment = bool(include_context or group_by_context or not include_storage) if context_enrichment and files: enriched = 0 for item in files: @@ -16224,6 +18940,7 @@ def metadata_saved_state_changes_list(payload: dict[str, Any]) -> dict[str, Any] if context: item["context"] = context enriched += 1 + enrich_saved_state_change_context_families(files) files.sort(key=lambda item: (str(item.get("table") or ""), str(item.get("status") or ""), str(item.get("file_name") or ""))) groups = build_saved_state_change_groups(base_id, files) if group_by_context else [] if group_by_context: @@ -16243,6 +18960,7 @@ def metadata_saved_state_changes_list(payload: dict[str, Any]) -> dict[str, Any] "group_by_context": bool(group_by_context), "context_enrichment": context_enrichment, "context_limit": int(context_limit or 0), + "include_storage": include_storage, }, "tables": table_summaries, "counts": counts, @@ -16260,7 +18978,71 @@ def metadata_saved_state_changes_list(payload: dict[str, Any]) -> dict[str, Any] recommended_action = saved_state_changes_recommended_action(groups) if recommended_action: result["recommended_next_action"] = recommended_action - return result + if include_storage: + return result + public_layers = [ + { + "layer": "base_saved_state" if str(summary.get("table") or "") == "ConfigSave" else "extension_saved_state", + "status": summary.get("status"), + "counts": public_saved_state_layer_counts(summary.get("counts")), + "freshness": public_saved_state_freshness(summary.get("freshness")), + } + for summary in table_summaries + ] + public_files = [public_saved_state_change_item(item) for item in files] + public_errors = [ + { + "layer": "base_saved_state" if str(error.get("table") or "") == "ConfigSave" else "extension_saved_state", + "status": error.get("status"), + "error": error.get("error"), + "diagnostics": { + "status": "layer_unavailable", + "message": "Saved-state layer could not be inspected; storage diagnostics are hidden unless include_storage=true.", + }, + } + for error in errors + ] + public_counts = { + "layers": counts.get("tables", 0), + "changes": counts.get("files", 0), + "changed": counts.get("changed_files", 0), + "saved_only": counts.get("saved_only_files", 0), + "unchanged": counts.get("unchanged_files", 0), + "error_layers": counts.get("error_tables", 0), + **({"groups": counts.get("groups")} if counts.get("groups") is not None else {}), + } + public_result = { + "schema": "onec_saved_state_changes_list.v1", + "method": method, + "status": status, + "base_id": base_id, + "source": {"kind": "saved_state"}, + "query": { + "layers": ["base_saved_state" if item == "ConfigSave" else "extension_saved_state" for item in tables], + "limit": int(limit or 500), + "include_unchanged": bool(include_unchanged), + "include_context": True, + "group_by_context": bool(group_by_context), + "context_enrichment": True, + "context_limit": int(context_limit or 0), + "include_storage": False, + }, + "layers": public_layers, + "counts": public_counts, + "changes": public_files, + "errors": public_errors, + "freshness": public_saved_state_freshness(result["freshness"]), + "diagnostics": { + "note": "SQL coordinates, hashes, and low-level actions are hidden unless include_storage=true.", + "resolved_contexts": len([item for item in public_files if item.get("context")]), + "unresolved_contexts": len([item for item in public_files if not item.get("context")]), + }, + } + if group_by_context: + public_groups = [public_saved_state_change_group(group, index) for index, group in enumerate(groups, start=1)] + public_result["groups"] = public_groups + public_result["counts"] = {**public_counts, "groups": len(public_groups)} + return public_result def storage_apply_backup_dir() -> Path: @@ -16785,14 +19567,51 @@ def storage_saved_state_apply_proposal(payload: dict[str, Any]) -> dict[str, Any def repository_write_context(payload: dict[str, Any]) -> dict[str, Any]: - """Carry repository coordination through nested write operations.""" + """Carry resolved repository/support coordination through nested writes.""" return { key: payload[key] - for key in ("lock_session_id", "repository_object") + for key in ("lock_session_id", "repository_object", "support_object_guid", "extension_guid", "owner_resolution") if payload.get(key) is not None } +REPOSITORY_WRITE_CONTEXT_KEYS = ("lock_session_id", "repository_object", "layer_id") + + +def normalize_repository_write_context(payload: dict[str, Any], method: str) -> tuple[dict[str, Any], dict[str, Any] | None]: + """Accept the confirmation write_context directly or under repository_lock. + + Top-level fields remain the canonical transport. Nested forms exist so a + client can forward repository.lock.confirm.write_context verbatim. + """ + normalized = dict(payload) + for container_name in ("write_context", "repository_lock"): + context = payload.get(container_name) + if context is None: + continue + if not isinstance(context, dict): + return payload, { + "status": "blocked", "error": "invalid_repository_lock_context", + "argument": container_name, + "message": f"{container_name} must be an object returned by repository.lock.confirm.write_context.", + } + for key in REPOSITORY_WRITE_CONTEXT_KEYS: + nested_value = context.get(key) + if nested_value is None: + continue + current_value = normalized.get(key) + if current_value is not None and str(current_value).strip() != str(nested_value).strip(): + return payload, { + "status": "blocked", "error": "repository_lock_context_conflict", + "argument": key, + "message": f"Top-level {key} conflicts with {container_name}.{key}.", + "top_level": current_value, + "nested": nested_value, + } + normalized[key] = nested_value + return normalized, None + + def storage_saved_state_rollback(payload: dict[str, Any]) -> dict[str, Any]: method = "storage.saved_state.rollback" base_id_or_error = require_base_id(payload, method) @@ -16929,7 +19748,7 @@ def metadata_dbnames_summary(payload: dict[str, Any]) -> dict[str, Any]: return result try: - from parser.dbnames import parse_dbnames_bytes + from parser.dbnames import parse_dbnames_bytes, parse_dbnames_version_bytes except Exception as exc: return { "schema": "onec_metadata_dbnames_summary.v1", @@ -16944,6 +19763,10 @@ def metadata_dbnames_summary(payload: dict[str, Any]) -> dict[str, Any]: summaries = [] total_records = 0 role_counts: dict[str, int] = {} + main_dbnames_ok = False + version_seen = False + version_token: str | None = None + fallback_tokens: list[str] = [] for file_row in files.get("files") or []: file_name = str(file_row.get("FileName") or "") if not file_name.startswith("DBNames") or file_name.startswith("DBNamesVersion-"): @@ -16953,11 +19776,33 @@ def metadata_dbnames_summary(payload: dict[str, Any]) -> dict[str, Any]: summaries.append({"file_name": file_name, "status": "error", "diagnostics": error.get("diagnostics")}) continue try: - parsed = parse_dbnames_bytes(data, source=file_name) + parsed = ( + parse_dbnames_version_bytes(data, source=file_name) + if file_name == "DBNamesVersion" + else parse_dbnames_bytes(data, source=file_name) + ) except Exception as exc: summaries.append({"file_name": file_name, "status": "error", "diagnostics": {"message": str(exc)}}) continue + if file_name == "DBNamesVersion": + version_seen = True + version_token = str(parsed.get("version") or "") or None + summaries.append( + { + "file_name": file_name, + "status": "ok", + "bytes": len(data), + "sha1": hashlib.sha1(data).hexdigest(), + "compression": parsed.get("compression"), + "encoding": parsed.get("encoding"), + "marker": parsed.get("marker"), + "version": parsed.get("version"), + } + ) + continue records = parsed.get("records") or [] + main_dbnames_ok = True + fallback_tokens.append(hashlib.sha1(data).hexdigest()) total_records += len(records) local_roles: dict[str, int] = {} for record in records: @@ -16977,14 +19822,27 @@ def metadata_dbnames_summary(payload: dict[str, Any]) -> dict[str, Any]: "role_counts": dict(sorted(local_roles.items())), } ) + has_errors = any(item.get("status") == "error" for item in summaries) + status = "ok" if main_dbnames_ok and version_seen and not has_errors else ("degraded" if main_dbnames_ok else "error") + fallback_token = None + if len(fallback_tokens) == 1: + fallback_token = fallback_tokens[0] + elif fallback_tokens: + fallback_token = hashlib.sha1("|".join(sorted(fallback_tokens)).encode("ascii")).hexdigest() + freshness_token = version_token or fallback_token return { "schema": "onec_metadata_dbnames_summary.v1", - "status": "ok", + "status": status, "base_id": base_id, "source": {"kind": "live_sql", "table": "Params"}, "dbnames": summaries, "counts": {"files": len(summaries), "records": total_records}, "role_counts": dict(sorted(role_counts.items())), + "freshness": { + "token": freshness_token, + "source": "DBNamesVersion" if version_token else "dbnames_sha1", + "degraded": status == "degraded", + }, } @@ -17006,7 +19864,11 @@ def live_dbnames_records(base_id: str, *, limit_files: int = 200, timeout_second records = [] for file_row in files.get("files") or []: file_name = str(file_row.get("FileName") or "") - if not file_name.startswith("DBNames") or file_name.startswith("DBNamesVersion-"): + if ( + not file_name.startswith("DBNames") + or file_name == "DBNamesVersion" + or file_name.startswith("DBNamesVersion-") + ): continue data, _, error = read_storage_file_bytes(base_id, "Params", file_name, timeout_seconds=timeout_seconds) if error: @@ -19494,7 +22356,13 @@ def extension_module_owner_payload( source_item = next((source for source in sources or [] if isinstance(source, dict)), {}) extension = source_item.get("extension") if isinstance(source_item.get("extension"), dict) else {} _, _, candidate_stream_index = parse_module_id(candidate_module_id) - public_module = public_module_row(module, include_storage=False, ordinal=ordinal, owner_kind=object_info.get("kind")) + public_module = public_module_row( + module, + include_storage=False, + ordinal=ordinal, + owner_kind=object_info.get("kind"), + owner_name=object_info.get("name"), + ) return { "owner": { "status": "resolved" if object_info.get("guid") else "partial", @@ -19809,6 +22677,7 @@ def read_module(payload: dict[str, Any]) -> dict[str, Any]: include_storage=False, ordinal=int(payload.get("module_ordinal") or payload.get("module_index") or payload.get("module_number") or 1), owner_kind=owner_context.get("owner", {}).get("kind") if isinstance(owner_context.get("owner"), dict) else None, + owner_name=owner_context.get("owner", {}).get("name") if isinstance(owner_context.get("owner"), dict) else None, )} if selected_module else ({"module": saved_state_module_role} if saved_state_module_role else {}) @@ -19875,6 +22744,7 @@ def read_module(payload: dict[str, Any]) -> dict[str, Any]: include_storage=False, ordinal=int(payload.get("module_ordinal") or payload.get("module_index") or payload.get("module_number") or 1), owner_kind=owner_context.get("owner", {}).get("kind") if isinstance(owner_context.get("owner"), dict) else None, + owner_name=owner_context.get("owner", {}).get("name") if isinstance(owner_context.get("owner"), dict) else None, ) if selected_module else {"kind": "bsl_container_module" if extraction.get("status") == "ok" else "container_payload", "name": "BSL module"} @@ -20288,10 +23158,25 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: extension_target_files_by_table.setdefault(table_name, set()).add(file_name) if not extension_target_module_refs: extension_route_fallback_scan = True + manifests, manifest_diagnostics = live_extension_manifests( + base_id_or_error, + extension_guid=extension_guid, + timeout_seconds=int(timeout_seconds or 60), + ) + manifest_keys = { + str(entry.get("cas_key") or "").strip().lower() + for manifest in manifests + for entry in (manifest.get("entries") or []) + if isinstance(entry, dict) and str(entry.get("cas_key") or "").strip() + } + if manifest_keys: + extension_target_files_by_table["ConfigCAS"] = manifest_keys extension_route_fallback_diagnostics.append( { "code": "extension_module_owner_routes_not_found", - "message": f"В расширении `{extension_filter}` модульные записи не найдены через DBNames owner routes; выполняется fallback scan по ConfigCAS/ConfigCASSave.", + "message": f"В расширении `{extension_filter}` модульные записи не найдены через DBNames owner routes; выполняется fallback scan по manifest ConfigCAS.", + "manifest_entries": len(manifest_keys), + "manifest_diagnostics": manifest_diagnostics, } ) cache_config, _ = sql_config_for_base(base_id_or_error) @@ -20610,30 +23495,30 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: skipped_active_extension_fallback = False for candidate_table in tables_to_scan: - if ( - extension_filter - and extension_route_fallback_scan - and candidate_table in {"ConfigCAS", "Config"} - and not extension_target_files_by_table - and not full_scan - ): - skipped_active_extension_fallback = True - continue remaining_budget = max(0, scan_budget - scanned_files_total) if remaining_budget <= 0: break files_payload = {"base_id": base_id_or_error, "table": candidate_table, "limit": remaining_budget, "_internal": True} effective_prefix = prefix - if not effective_prefix and extension_filter and extension_guid and candidate_table in {"ConfigCASSave", "ConfigSave"}: + if not effective_prefix and extension_filter and extension_guid and candidate_table in {"ConfigCAS", "ConfigCASSave"}: effective_prefix = f"{extension_guid}__" if effective_prefix: files_payload["prefix"] = effective_prefix - candidate_files = storage_files_list(files_payload) - if candidate_files.get("status") != "ok": - result = dict(candidate_files) - result["method"] = "modules.search" - return result - file_names = [str(row.get("FileName") or "") for row in candidate_files.get("files") or []] + manifest_direct_files = ( + extension_route_fallback_scan + and candidate_table == "ConfigCAS" + and bool(extension_target_files_by_table.get("ConfigCAS")) + ) + if manifest_direct_files: + file_names = sorted(extension_target_files_by_table.get("ConfigCAS") or set())[:remaining_budget] + candidate_files = {"status": "ok", "files": [{"FileName": name} for name in file_names], "truncated": False} + else: + candidate_files = storage_files_list(files_payload) + if candidate_files.get("status") != "ok": + result = dict(candidate_files) + result["method"] = "modules.search" + return result + file_names = [str(row.get("FileName") or "") for row in candidate_files.get("files") or []] if extension_target_files_by_table: file_names = [name for name in file_names if name in extension_target_files_by_table.get(candidate_table, set())] if not file_names: @@ -20760,7 +23645,13 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: **owner_payload, }, "module": { - "name": public_module_row(module, include_storage=False, ordinal=ordinal, owner_kind=owner_payload.get("kind")).get("name"), + "name": public_module_row( + module, + include_storage=False, + ordinal=ordinal, + owner_kind=owner_payload.get("kind"), + owner_name=owner_payload.get("name"), + ).get("name"), "module_ordinal": ordinal, "form": None, }, @@ -20822,7 +23713,13 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: "guid": obj.get("guid"), }, "module": { - "name": public_module_row(module, include_storage=False, ordinal=ordinal, owner_kind=obj.get("kind")).get("name"), + "name": public_module_row( + module, + include_storage=False, + ordinal=ordinal, + owner_kind=obj.get("kind"), + owner_name=obj.get("name"), + ).get("name"), "module_ordinal": ordinal, "form": None, }, @@ -21223,6 +24120,7 @@ def code_saved_state_common_form_module_search(payload: dict[str, Any], *, query "scan_limit": int(scan_limit or 300), "preview_chars": int(payload.get("max_chars") or 100000), "timeout_seconds": int(timeout_seconds or 60), + "include_storage": True, } ) if search.get("status") != "ok" or not search.get("modules"): @@ -21331,6 +24229,7 @@ def code_saved_state_common_form_read(payload: dict[str, Any], *, include_text: "scan_limit": 1000, "preview_chars": int(max_chars or 100000), "timeout_seconds": int(timeout_seconds or 60), + "include_storage": True, } ) streams: list[dict[str, Any]] = [] @@ -21484,10 +24383,25 @@ def code_search_both_response(base_id: str, saved_result: dict[str, Any], active } +def public_code_origin(origin: Any, *, include_storage: bool) -> Any: + """Expose source semantics without making SQL storage a programming selector.""" + if not isinstance(origin, dict) or include_storage: + return origin + return { + key: value + for key, value in origin.items() + if key not in {"storage_table", "table", "file_name", "module_id"} + } + + def code_search(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "code.search") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload + payload = normalize_configuration_view(payload, "code.search") + if payload.get("status") == "invalid_argument": + return payload + configuration_view = str(payload.get("configuration_view") or "effective_working") base_id_or_error = require_base_id(payload, "code.search") if isinstance(base_id_or_error, dict): return base_id_or_error @@ -21544,17 +24458,22 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: ) if saved_state_result is not None: if state == "both": - active_result = code_search({**payload, "base_id": base_id_or_error, "state": "active"}) - return code_search_both_response(base_id_or_error, saved_state_result, active_result) - return saved_state_result + active_result = code_search( + { + **payload, + "base_id": base_id_or_error, + "state": "active", + "configuration_view": "runtime_applied", + } + ) + return annotate_configuration_view(code_search_both_response(base_id_or_error, saved_state_result, active_result), configuration_view) + return annotate_configuration_view(saved_state_result, configuration_view) search_payload = { "base_id": base_id_or_error, "query": query, "include_storage": bool(include_storage), "scan_limit": int(scan_limit or 300), "limit": int(limit or 25) + int(offset or 0), - "extension": payload.get("extension"), - "extension_guid": payload.get("extension_guid"), "routine_name": payload.get("routine_name"), "scope": payload.get("scope", "auto"), "table": payload.get("table", "auto"), @@ -21566,6 +24485,8 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: **({"kind": object_selector.get("kind")} if object_selector.get("kind") else {}), **({"name": object_selector.get("name")} if object_selector.get("name") is not None else {}), **({"guid": object_selector.get("guid")} if object_selector.get("guid") is not None else {}), + **({"extension": payload.get("extension")} if payload.get("extension") not in {None, ""} else {}), + **({"extension_guid": payload.get("extension_guid")} if payload.get("extension_guid") not in {None, ""} else {}), **( {"module_ordinal": module_ordinal} if module_ordinal is not None and payload.get("module_ordinal") is not None @@ -21625,7 +24546,7 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: "column": item_column, "context": context_text, "resolved_owner": match.get("owner"), - "origin": match.get("origin"), + "origin": public_code_origin(match.get("origin"), include_storage=bool(include_storage)), "module": match.get("module"), **({"qualified_name": match.get("qualified_name")} if match.get("qualified_name") else {}), **({"display_name": match.get("display_name")} if match.get("display_name") else {}), @@ -21640,9 +24561,12 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: item = items[-1] item.pop("context", None) item["match"] = match.get("match") or query - return { + public_status = modules_result.get("status") or ("ok" if items else "not_found") + if not items and public_status == "ok": + public_status = "not_found" + return annotate_configuration_view({ "schema": "onec_code_search.v1", - "status": modules_result.get("status") or ("ok" if items else "not_found"), + "status": public_status, "base_id": base_id_or_error, "source": modules_result.get("source"), "object": resolved_object, @@ -21674,13 +24598,17 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: "owner_indexed_module_refs": int(modules_result.get("counts", {}).get("owner_indexed_module_refs") or 0), }, "diagnostics": modules_result.get("diagnostics"), - } + }, configuration_view) def code_read(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "code.read") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": return payload + payload = normalize_configuration_view(payload, "code.read") + if payload.get("status") == "invalid_argument": + return payload + configuration_view = str(payload.get("configuration_view") or "effective_working") base_id_or_error = require_base_id(payload, "code.read") if isinstance(base_id_or_error, dict): return base_id_or_error @@ -21723,6 +24651,24 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: } saved_read_payload.pop("include_line_numbers", None) saved_state_result = read_module(saved_read_payload) + if isinstance(saved_state_result, dict) and saved_state_result.get("status") == "source_missing": + saved_table, saved_file_name, saved_stream = parse_module_id(module_ref_value) + active_table = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"}.get(saved_table, "") + if active_table and saved_file_name: + active_module_ref = f"{active_table}:{saved_file_name}" + (f"#stream:{saved_stream}" if saved_stream is not None else "") + active_fallback = read_module({ + **saved_read_payload, + "module_ref": active_module_ref, + "module_id": active_module_ref, + "state": "active", + }) + if isinstance(active_fallback, dict) and active_fallback.get("status") in {"ok", "summary", "text"}: + saved_state_result = dict(active_fallback) + saved_state_result["current_state"] = {"source": "active_fallback", "activation_state": "active"} + saved_state_result["diagnostics"] = { + **(active_fallback.get("diagnostics") if isinstance(active_fallback.get("diagnostics"), dict) else {}), + "message": "Saved working copy was absent; the effective view fell back to the corresponding active module.", + } if isinstance(saved_state_result, dict): saved_state_result = dict(saved_state_result) saved_state_result["schema"] = "onec_code_read.v1" @@ -21742,9 +24688,14 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: timeout_seconds=int(timeout_seconds or 60), ) if saved_state_result is not None and state != "both": - return saved_state_result + saved_state_result = enrich_code_read_logical_owner(saved_state_result, payload, base_id=base_id_or_error) + return attach_effective_routine_chain( + annotate_configuration_view(saved_state_result, configuration_view), + payload, + view=configuration_view, + ) if state == "save" and (is_saved_state_common_form_request or saved_state_module_ref): - return { + return annotate_configuration_view({ "schema": "onec_code_read.v1", "method": "code.read", "status": "not_found", @@ -21752,7 +24703,7 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: "base_id": base_id_or_error, "current_state": {"source": "saved_state", "activation_state": "not_activated"}, "diagnostics": {"message": "Saved-state code was not found for the requested CommonForm selector."}, - } + }, configuration_view) read_payload = { "base_id": base_id_or_error, "include_storage": False, @@ -21772,7 +24723,7 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: if state == "both" and saved_state_module_ref and not any( [object_selector.get("kind"), object_selector.get("guid"), object_selector.get("name"), first_non_empty_arg(payload, "module_ordinal", "module_index", "module_number")] ): - return code_read_both_response( + return annotate_configuration_view(code_read_both_response( payload, base_id=base_id_or_error, saved_result=saved_state_result, @@ -21785,7 +24736,7 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: "diagnostics": {"message": "Active-layer comparison for a saved-state module_ref requires an owner selector or module ordinal; the saved module_ref itself is not an active-layer handle."}, }, include_text=bool(include_text), - ) + ), configuration_view) read_payload["max_chars"] = max_chars if "mode" in read_payload and read_payload["mode"] is not None and str(read_payload["mode"]).strip() == "summary": read_payload["mode"] = "summary" @@ -21795,14 +24746,14 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: result["method"] = "code.read" result["current_state"] = {"source": "active", "activation_state": "active"} if state == "both": - return code_read_both_response( + return annotate_configuration_view(code_read_both_response( payload, base_id=base_id_or_error, saved_result=saved_state_result, active_result=result, include_text=bool(include_text), - ) - return result + ), configuration_view) + return annotate_configuration_view(result, configuration_view) result["schema"] = "onec_code_read.v1" result["method"] = "code.read" result["source"] = { @@ -21812,13 +24763,13 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: } result["current_state"] = {"source": "active", "activation_state": "active"} if state == "both": - return code_read_both_response( + return annotate_configuration_view(code_read_both_response( payload, base_id=base_id_or_error, saved_result=saved_state_result, active_result=result, include_text=bool(include_text), - ) + ), configuration_view) if truthy(include_line_numbers): selection = result.get("selection") or {} if selection.get("line_start") is not None: @@ -21836,6 +24787,7 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: "guid": object_selector.get("guid"), "name": object_selector.get("name"), } + result = enrich_code_read_logical_owner(result, payload, base_id=base_id_or_error) result["resolved_selector"] = { "base_id": base_id_or_error, **({ "kind": object_selector.get("kind")} if object_selector.get("kind") else {}), @@ -21844,7 +24796,11 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: **({"module_id": result.get("module_id")} if result.get("module_id") else {}), **({"module_ordinal": first_non_empty_arg(payload, "module_ordinal", "module_index", "module_number")} if first_non_empty_arg(payload, "module_ordinal", "module_index", "module_number") is not None else {}), } - return result + return attach_effective_routine_chain( + annotate_configuration_view(result, configuration_view), + payload, + view=configuration_view, + ) def _code_binding_extract_placeholders(text: str) -> list[str]: @@ -21951,6 +24907,20 @@ def metadata_extension_action_from_evidence( break if raw_action: break + if not raw_action and source == "extension": + source_text = str((read_result or {}).get("text") or (read_result or {}).get("preview") or "") + annotation = re.search( + r"&\s*(Перед|После|Вместо|ИзменениеИКонтроль)\s*\(\s*[\"']?([^\"'\)\s]+)", + source_text, + re.IGNORECASE, + ) + if annotation and normalize(annotation.group(2)) == normalize(method_name): + raw_action = { + "перед": "insert_before", + "после": "insert_after", + "вместо": "replace", + "изменениеиконтроль": "replace_with_control", + }.get(normalize(annotation.group(1)), "") if raw_action: operation_class = metadata_write_plan_operation_class(raw_action) else: @@ -21998,6 +24968,7 @@ def metadata_resolve_overrides_write_plan_evidence( object_kind = object_payload.get("kind") or object_payload.get("type") if object_payload else None object_name = object_payload.get("name") if object_payload else None object_guid = object_payload.get("guid") if object_payload else None + object_ref = object_selector_ref(str(object_kind or ""), str(object_name or "")) evidence: dict[str, Any] = { "method": METADATA_WRITE_PLAN_METHOD, "base_id": base_id, @@ -22011,7 +24982,7 @@ def metadata_resolve_overrides_write_plan_evidence( "base_id": base_id, "query": method_name, "limit": 10, - **({"owner_guid": object_guid} if object_guid else {}), + **({"ref": object_ref} if object_ref else {}), }, }, "diagnostics": { @@ -22038,12 +25009,12 @@ def metadata_resolve_overrides_write_plan_evidence( ] if len(extension_actions) == 1: evidence["target"]["extension_action"] = extension_actions[0] - evidence["next_resolution"]["params"]["tables"] = ["ConfigCASSave"] + evidence["next_resolution"]["params"]["layer"] = "extension_saved_state" action_class = metadata_write_plan_operation_class(str(extension_actions[0].get("operation_class") or "")) if action_class not in {"", "unknown_extension_action", "base_definition"}: evidence["intent"] = {"operation": action_class} elif extension_actions: - evidence["next_resolution"]["params"]["tables"] = ["ConfigCASSave"] + evidence["next_resolution"]["params"]["layer"] = "extension_saved_state" evidence["extension_actions"] = extension_actions evidence["diagnostics"]["message"] = "Multiple extension actions were found; narrow the extension/module before building metadata.write.plan." else: @@ -22055,7 +25026,7 @@ def metadata_resolve_overrides_write_plan_evidence( and item.get("extension_action", {}).get("operation_class") == "base_definition" ] if base_actions: - evidence["next_resolution"]["params"]["tables"] = ["ConfigSave"] + evidence["next_resolution"]["params"]["layer"] = "base_saved_state" return evidence @@ -22206,7 +25177,13 @@ def metadata_resolve_overrides(payload: dict[str, Any]) -> dict[str, Any]: "match_by": selection.get("match_by"), "module": { "module_ordinal": ordinal, - "name": public_module_row(module, include_storage=False, ordinal=ordinal, owner_kind=object_ref.get("kind")).get("name"), + "name": public_module_row( + module, + include_storage=False, + ordinal=ordinal, + owner_kind=object_ref.get("kind"), + owner_name=object_ref.get("name"), + ).get("name"), }, "read_selector": { "base_id": base_id_or_error, @@ -22562,7 +25539,7 @@ def changes_propose(payload: dict[str, Any]) -> dict[str, Any]: stream_mode = any(isinstance(edit, dict) and "stream_index" in edit for edit in edits) path_mode = any(isinstance(edit, dict) and "path" in edit for edit in edits) - structural_mode = any(isinstance(edit, dict) and ("swap_paths" in edit or "append_child" in edit) for edit in edits) + structural_mode = any(isinstance(edit, dict) and ("swap_paths" in edit or "append_child" in edit or "replace_root" in edit) for edit in edits) if sum(1 for value in (stream_mode, path_mode, structural_mode) if value) > 1: return { "schema": "onec_adapter_request_error.v1", @@ -22658,6 +25635,31 @@ def changes_propose(payload: dict[str, Any]) -> dict[str, Any]: for index, edit in enumerate(edits): if not isinstance(edit, dict): raise ValueError(f"edit {index} is not an object") + if "replace_root" in edit: + replacement_root = edit.get("replace_root") + if not isinstance(replacement_root, dict) or replacement_root.get("type") not in {"list", "sequence"}: + raise ValueError(f"edit {index} replace_root must be a parsed list/sequence node") + brace_offset = proposal_text.find("{") + if brace_offset < 0: + raise ValueError("replace_root requires a brace-tree text payload") + old_root_text = proposal_text[brace_offset:] + new_root_text = serialize_brace_tree(replacement_root) + proposal_text = proposal_text[:brace_offset] + new_root_text + applied_edits.append( + { + "index": index, + "mode": "structural_replace_root_preserve_prefix", + "old_tree_sha1": hashlib.sha1(old_root_text.encode("utf-8")).hexdigest(), + "new_tree_sha1": hashlib.sha1(new_root_text.encode("utf-8")).hexdigest(), + "old_tree_chars": len(old_root_text), + "new_tree_chars": len(new_root_text), + "fields": list(edit.get("fields") or []), + "resized_collections": list(edit.get("resized_collections") or []), + "old_counts": dict(edit.get("old_counts") or {}), + "new_counts": dict(edit.get("new_counts") or {}), + } + ) + continue if "swap_paths" in edit: swap_paths = edit.get("swap_paths") if not isinstance(swap_paths, list) or len(swap_paths) != 2: @@ -22807,6 +25809,39 @@ def changes_propose(payload: dict[str, Any]) -> dict[str, Any]: "ok": bool(get_tree_path(roundtrip_tree, path_a) and get_tree_path(roundtrip_tree, path_b)), } ) + elif edit.get("mode") == "structural_append_child_preserve_format": + parent_path = str(edit.get("parent_path") or "") + inserted_index = edit.get("inserted_index") + appended_path = f"{parent_path}.{inserted_index}" if parent_path and isinstance(inserted_index, int) else "" + appended_node = get_tree_path(roundtrip_tree, appended_path) if appended_path else None + parent_node = get_tree_path(roundtrip_tree, parent_path) if parent_path else None + parent_items = config_tree_list_items(parent_node) + declared_count = config_tree_scalar(parent_items[1]) if len(parent_items) >= 2 else "" + count_ok = ( + not edit.get("count_updated") + or (declared_count.isdigit() and int(declared_count) == len(parent_items) - 2) + ) + checks.append( + { + "operation": "append_child", + "parent_path": parent_path, + "inserted_index": inserted_index, + "appended_path": appended_path, + "node_present": appended_node is not None, + "count_ok": count_ok, + "ok": appended_node is not None and count_ok, + } + ) + elif edit.get("mode") == "structural_replace_root_preserve_prefix": + roundtrip_serialized = serialize_brace_tree(roundtrip_tree) + checks.append( + { + "operation": "replace_root", + "expected_tree_sha1": edit.get("new_tree_sha1"), + "actual_tree_sha1": hashlib.sha1(roundtrip_serialized.encode("utf-8")).hexdigest(), + "ok": hashlib.sha1(roundtrip_serialized.encode("utf-8")).hexdigest() == edit.get("new_tree_sha1"), + } + ) validation = { "status": "ok", "mode": "path_preserve_format" if preserve_format else "path", @@ -25699,6 +28734,255 @@ def extension_object_read_selectors(base_id: str, kind: str | None, identity: di EXTENSION_OBJECTS_FIND_STATES = {"working", "active", "save", "both"} +# Public configuration views deliberately describe 1C semantics, rather than +# the SQL tables used to recover them. ``working`` has historically been the +# adapter default, but callers should not have to know that this means a +# Designer snapshot with saved changes preferred over the applied snapshot. +CONFIGURATION_VIEW_TO_STATE = { + "effective": "working", + "effective_working": "working", + "designer": "working", + "working": "working", + "runtime": "active", + "runtime_applied": "active", + "applied": "active", + "compare": "both", + "comparison": "both", + "both": "both", + "saved": "save", + "save": "save", +} +CONFIGURATION_VIEW_BY_STATE = { + "working": "effective_working", + "active": "runtime_applied", + "both": "compare", + "save": "saved_only", +} + + +def normalize_configuration_view(payload: dict[str, Any], method: str) -> dict[str, Any]: + """Normalize the agent-facing configuration view into the storage-neutral state. + + The result intentionally retains ``configuration_view`` for response + annotation, but all lower-level readers receive only the existing state + contract. Supplying contradictory ``state`` and ``configuration_view`` is + rejected instead of silently choosing one physical layer. + """ + normalized = dict(payload) + raw_view = normalized.get("configuration_view") + view = str(raw_view or "").strip().casefold() + explicit_state = str(normalized.get("state") or "").strip().casefold() + if view: + mapped_state = CONFIGURATION_VIEW_TO_STATE.get(view) + if not mapped_state: + return invalid_argument( + method, + "configuration_view", + "Unsupported configuration view.", + allowed_values=sorted(CONFIGURATION_VIEW_TO_STATE), + ) + if explicit_state and explicit_state != mapped_state: + return invalid_argument( + method, + "configuration_view", + "configuration_view conflicts with state.", + ) + normalized["state"] = mapped_state + normalized["configuration_view"] = CONFIGURATION_VIEW_BY_STATE[mapped_state] + else: + state = explicit_state or "working" + normalized["state"] = state + normalized["configuration_view"] = CONFIGURATION_VIEW_BY_STATE.get(state, "effective_working") + return normalized + + +def configuration_view_details(view: str) -> dict[str, Any]: + """Return the execution meaning of an agent-visible configuration view.""" + if view == "runtime_applied": + return { + "name": view, + "meaning": "Configuration currently applied to the infobase and executable now.", + "extension_resolution": "applied_extension_composition", + } + if view == "compare": + return { + "name": view, + "meaning": "Comparison of the effective working configuration and the currently applied configuration.", + "extension_resolution": "both_compositions", + } + if view == "saved_only": + return { + "name": view, + "meaning": "Only explicitly saved Designer changes; this is a diagnostic view, not a complete configuration.", + "extension_resolution": "saved_changes_only", + } + return { + "name": "effective_working", + "meaning": "Effective Designer configuration: base configuration plus saved development changes and discovered extension layers. It becomes executable after configuration update; it is not claimed to be the currently applied runtime.", + "extension_resolution": "working_extension_composition", + } + + +def annotate_configuration_view(result: dict[str, Any], view: str) -> dict[str, Any]: + """Keep SQL implementation details out of ordinary agent-facing results.""" + annotated = dict(result) + annotated["configuration_view"] = configuration_view_details(view) + return annotated + + +def effective_extension_routine_scan(base_id: str, routine_name: str, *, canonical_object: str) -> list[dict[str, Any]]: + """Inspect extensions in platform load order without claiming unproven ownership.""" + extensions = sorted( + (item for item in extension_map_by_guid(base_id).values() if item.get("active") is not False), + key=lambda item: (int(item.get("order") or 0), str(item.get("guid") or "")), + ) + layers: list[dict[str, Any]] = [] + for extension in extensions: + found = search_modules({ + "base_id": base_id, + "extension": extension.get("guid"), + "query": routine_name, + "routine_name": routine_name, + "state": "working", + "limit": 20, + "scan_limit": 1000, + "include_storage": False, + "timeout_seconds": 30, + }) + matches = [item for item in found.get("matches") or [] if isinstance(item, dict)] if isinstance(found, dict) else [] + proven = 0 + actions: list[dict[str, Any]] = [] + for match in matches: + selector = match.get("read_selector") if isinstance(match.get("read_selector"), dict) else {} + _table, file_name, _stream = parse_module_id(str(selector.get("module_ref") or selector.get("module_id") or "")) + form_match = _FORM_MODULE_FILE_RE.fullmatch(file_name or "") + if not form_match: + continue + resolved, _error = repository_resolve_form_guid_sql( + method="effective.execution_chain", + base_id=base_id, + form_guid=str(form_match.group("form") or "").lower(), + timeout_seconds=30, + ) + if resolved and str(resolved.get("resolved_object") or "") == canonical_object: + proven += 1 + read_result = read_module({ + "base_id": base_id, + "module_ref": selector.get("module_ref") or selector.get("module_id"), + "routine_name": routine_name, + "include_storage": False, + "include_text": True, + "max_chars": 100000, + }) + action = metadata_extension_action_from_evidence( + source="extension", method_name=routine_name, read_result=read_result, + module={"module_ref": selector.get("module_ref") or selector.get("module_id")}, + ) + action["extension"] = {key: extension.get(key) for key in ("name", "guid", "order")} + actions.append(action) + layers.append({ + "extension": {key: extension.get(key) for key in ("name", "guid", "order", "active")}, + "status": found.get("status") if isinstance(found, dict) else "error", + "routine_candidates": len(matches), + "proven_candidates": proven, + "applicability": "proven" if proven else "not_proven" if matches else "not_found", + "extension_actions": actions, + }) + return layers + + +def attach_effective_routine_chain(result: dict[str, Any], payload: dict[str, Any], *, view: str) -> dict[str, Any]: + """Attach the discovered extension/action chain to an ordinary routine read. + + This keeps the follow-up override analysis inside the adapter contract: an + agent reading a named routine must not infer extension replacement from + SQL-origin rows by itself. The chain remains evidence-based; an unknown + extension action is stated as unknown rather than guessed. + """ + routine_name = str(payload.get("routine_name") or "").strip() + if view != "effective_working" or not routine_name or result.get("status") not in {"ok", "summary", "text"}: + return result + selector, _ = _code_query_object_selector(payload) + if not selector.get("kind") and not selector.get("name") and not selector.get("guid"): + owner = result.get("resolved_owner") if isinstance(result.get("resolved_owner"), dict) else {} + canonical_object = str(owner.get("canonical_object") or "").strip() + if canonical_object: + extension_layers = effective_extension_routine_scan(str(payload.get("base_id") or ""), routine_name, canonical_object=canonical_object) + extension_actions = [ + action + for layer in extension_layers + for action in (layer.get("extension_actions") or []) + if isinstance(action, dict) + ] + execution_steps = [{ + "order": 1, + "source": "effective_working", + "target": canonical_object, + "role": "resolved_form_module", + }] + for index, action in enumerate(sorted(extension_actions, key=lambda item: int((item.get("extension") or {}).get("order") or 0)), start=2): + execution_steps.append({ + "order": index, + "source": "extension", + "extension": action.get("extension"), + "operation_class": action.get("operation_class"), + "role": "routine_interceptor", + }) + enriched = dict(result) + enriched["execution_chain"] = { + "status": "resolved_entry", + "routine": routine_name, + "steps": execution_steps, + "extension_actions": extension_actions, + "extension_layers": extension_layers, + "diagnostics": { + "message": "The selected form module was resolved to its logical owner. Active extensions were scanned in load order; only SQL-proven form matches contribute routine actions." + }, + } + return enriched + return result + try: + chain = metadata_resolve_overrides( + { + "base_id": payload.get("base_id"), + "object_type": selector.get("kind"), + "object_name": selector.get("name"), + "object_guid": selector.get("guid"), + "method_name": routine_name, + "state": "working", + **({"extension": payload.get("extension")} if payload.get("extension") else {}), + } + ) + except Exception: + return result + if not isinstance(chain, dict): + return result + enriched = dict(result) + enriched["execution_chain"] = { + "status": chain.get("status"), + "routine": routine_name, + "steps": chain.get("chain") or [], + "extension_actions": chain.get("extension_actions") or [], + "diagnostics": chain.get("diagnostics"), + } + return enriched + + +def enrich_code_read_logical_owner(result: dict[str, Any], payload: dict[str, Any], *, base_id: str) -> dict[str, Any]: + """Resolve an opaque form-module selector to a public owner for every read state.""" + context = resolve_write_gate_context({**payload, "base_id": base_id}) + resolution = context.get("owner_resolution") if isinstance(context.get("owner_resolution"), dict) else {} + if resolution.get("status") != "resolved": + return result + enriched = dict(result) + enriched["resolved_owner"] = { + "canonical_object": resolution.get("repository_object"), + "owner": resolution.get("owner"), + "form_guid": resolution.get("form_guid"), + "layer_id": resolution.get("layer_id"), + } + return enriched + def extension_saved_state_metadata_matches( *, @@ -25855,6 +29139,7 @@ def extension_saved_state_object_matches( "scan_limit": min(max(scan_limit, limit), 5000), "timeout_seconds": timeout_seconds, "max_targets": 1, + "include_storage": True, } if query: search_payload["form"] = query @@ -26717,6 +30002,45 @@ def metadata_definition_find(payload: dict[str, Any]) -> dict[str, Any]: return query_error if not query_value or not str(query_value).strip(): return invalid_argument(method, "query", "query must be a non-empty JSON string.") + areas_explicit = "areas" in payload or "scope" in payload + definition_path = parse_1c_object_path(query_value) + inferred_path_area: str | None = None + if definition_path.get("recognized_root") and definition_path.get("kind") and definition_path.get("name"): + member_path = list(definition_path.get("member_path") or []) + if member_path: + payload = dict(payload) + payload.setdefault("kind", definition_path["kind"]) + payload.setdefault("name", definition_path["name"]) + query_value = member_path[-1] + section = normalize(member_path[0]) if member_path else "" + if section in { + normalize("Реквизиты"), + normalize("Измерения"), + normalize("Ресурсы"), + normalize("ТабличныеЧасти"), + "attributes", + "dimensions", + "resources", + "tabularsections", + }: + inferred_path_area = "object" + elif section in {normalize("Формы"), "forms"}: + inferred_path_area = "form" + elif section in {normalize("Команды"), "commands"}: + inferred_path_area = "commands" + elif section in {normalize("Макеты"), "templates"}: + inferred_path_area = "templates" + elif section in { + normalize("Модули"), + normalize("МодульОбъекта"), + normalize("МодульМенеджера"), + normalize("МодульНабораЗаписей"), + "modules", + "objectmodule", + "managermodule", + "recordsetmodule", + } or definition_path.get("kind") in {"CommonModule", "CommonForm"}: + inferred_path_area = "modules" include_storage, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error @@ -26724,10 +30048,11 @@ def metadata_definition_find(payload: dict[str, Any]) -> dict[str, Any]: areas, areas_error = parse_definition_find_areas(payload) if areas_error: return areas_error - areas_explicit = "areas" in payload or "scope" in payload has_context_selector = bool(payload.get("guid") or payload.get("name") or payload.get("ref") or first_non_empty_arg(payload, "ordinal", "index", "object_index") not in {None, ""}) if not areas_explicit and has_context_selector: - if payload.get("form") or payload.get("form_guid") or payload.get("file_name"): + if inferred_path_area: + areas = [inferred_path_area] + elif payload.get("form") or payload.get("form_guid") or payload.get("file_name"): areas = ["form"] else: areas = ["object", "form", "commands", "templates", "modules", "extensions"] @@ -27219,6 +30544,50 @@ def metadata_definition_find(payload: dict[str, Any]) -> dict[str, Any]: for item in extension_matches: append_match(item) diagnostics.extend(extension_diagnostics) + # Extension route cache also contains forms and other CAS descriptors + # that are not present in the older DBNames-derived definition index. + route_result = extension_objects_find({ + "base_id": base_id, + "query": str(query_value), + "state": "working", + "limit": max(1, int(max_matches or 50) - len(matches)), + "scan_limit": min(int(max_items or 5000), 5000), + "include_storage": include_storage, + "use_cache": bool(use_cache), + "refresh_cache": bool(refresh_cache), + "full_scan": bool(refresh_cache), + "timeout_seconds": int(timeout_value or 90), + }) + if route_result.get("status") == "ok": + route_matches: list[dict[str, Any]] = [] + for item in route_result.get("objects") or []: + if not isinstance(item, dict): + continue + selectors = item.get("read_selectors") if isinstance(item.get("read_selectors"), dict) else {} + selector = selectors.get("form_decode") or selectors.get("card") or selectors.get("route") or {} + route_match = definition_match( + query=str(query_value), area="extensions", kind_ru=str(item.get("kind_ru") or "Определение расширения"), + location={"presentation": str(item.get("name") or ""), "section": "Расширения"}, + item=item, object_card=item, read_selector=selector, + ) + if route_match: + route_matches.append(route_match) + if route_matches: + # Validated form/object routes are directly readable and rank + # above broad DBNames definition candidates. + route_guids = { + str(((match.get("object") or {}).get("guid") or "")).lower() + for match in route_matches + if str(((match.get("object") or {}).get("guid") or "")).strip() + } + matches[:] = [ + match for match in matches + if str(match.get("guid") or ((match.get("object") or {}).get("guid") or "")).lower() not in route_guids + ] + matches[0:0] = route_matches + del matches[int(max_matches or 50):] + else: + diagnostics.append({"area": "extensions", "route_cache_status": route_result.get("status"), "diagnostics": route_result.get("diagnostics")}) append_object_card_metadata_match(identity_match_by) @@ -30934,6 +34303,9 @@ def template_query_variants(query: str) -> list[str]: def templates_areas_find(payload: dict[str, Any]) -> dict[str, Any]: method = "templates.areas.find" + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload payload = normalize_template_route_ref_payload(payload) base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): @@ -32579,6 +35951,190 @@ def templates_map(payload: dict[str, Any]) -> dict[str, Any]: return result +def metadata_command_group_commands( + *, + base_id: str, + object_card: dict[str, Any], + requested_command: str | None, + include_storage: bool, + table: str, + max_commands: int, + timeout_seconds: int, +) -> dict[str, Any]: + object_guid = str(object_card.get("guid") or "").strip().lower() + if not is_guid_text(object_guid): + return { + "schema": "onec_object_commands.v1", + "status": "blocked", + "base_id": base_id, + "object": public_metadata_row(object_card, include_storage=include_storage), + "commands": [], + "object_commands": [], + "form_commands": [], + "counts": {"commands": 0, "object_commands": 0, "form_commands": 0, "scanned_common_commands": 0}, + "capabilities": {"object_commands": False, "form_commands": False, "reason": "CommandGroup GUID was not resolved."}, + } + if table not in {"Config", "ConfigSave"}: + return { + "schema": "onec_object_commands.v1", + "status": "ok", + "base_id": base_id, + "source": {"kind": "live_metadata"}, + "object": public_metadata_row(object_card, include_storage=include_storage), + "query": {"command": requested_command, "include_storage": include_storage}, + "commands": [], + "object_commands": [], + "form_commands": [], + "counts": {"commands": 0, "object_commands": 0, "form_commands": 0, "scanned_common_commands": 0}, + "capabilities": { + "object_commands": False, + "form_commands": False, + "reason": "CommandGroup reverse lookup currently supports base Config/ConfigSave only.", + }, + } + + listed = list_objects( + "CommonCommand", + base_id=base_id, + limit=5000, + offset=0, + include_storage=False, + include_missing=False, + only_missing=False, + exact_counts=True, + refresh_cache=False, + table="Config", + ) + if listed.get("status") != "ok": + result = dict(listed) + result["method"] = "metadata.object.commands" + return result + common_commands = [item for item in listed.get("objects") or [] if isinstance(item, dict) and is_guid_text(item.get("guid"))] + file_names = [str(item["guid"]).lower() for item in common_commands] + active_files, _, active_error = read_storage_files_bytes( + base_id, + "Config", + file_names, + timeout_seconds=timeout_seconds, + ) + if active_error: + result = dict(active_error) + result["method"] = "metadata.object.commands" + return result + effective_files = dict(active_files or {}) + saved_overrides = 0 + saved_files: dict[str, bytes] = {} + if table == "ConfigSave": + saved_files, _, saved_error = read_storage_files_bytes( + base_id, + "ConfigSave", + file_names, + timeout_seconds=timeout_seconds, + ) + if saved_error: + result = dict(saved_error) + result["method"] = "metadata.object.commands" + return result + for file_name, data in (saved_files or {}).items(): + effective_files[file_name] = data + saved_overrides += 1 + + reverse_index = index_common_command_groups(common_commands, effective_files) + wanted = normalize(requested_command or "") + matches: list[dict[str, Any]] = [] + undecodable = int(reverse_index["undecodable"]) + source_missing = int(reverse_index["source_missing"]) + for item in reverse_index["groups"].get(object_guid, []): + guid = str(item.get("guid") or "").lower() + command = { + "scope": "common", + "kind": "CommonCommand", + "guid": guid, + "name": item.get("name"), + "synonym": item.get("synonym"), + "ref": item.get("ref") or object_selector_ref("CommonCommand", str(item.get("name") or "")), + "module": { + "kind": "command_module", + "name": "Модуль команды", + "read_selector": { + "method": "modules.read", + "base_id": base_id, + "ref": item.get("ref") or object_selector_ref("CommonCommand", str(item.get("name") or "")), + "module_ordinal": 1, + "state": "working", + }, + }, + } + if wanted: + match_by = command_match_by(command, requested_command) + if not match_by: + continue + command["match_by"] = match_by + if include_storage: + command["storage"] = { + "table": "ConfigSave" if guid in (saved_files or {}) else "Config", + "file_name": guid, + "relation": "common_command.group_guid", + } + matches.append(command) + + total_matches = len(matches) + commands = matches[:max_commands] + if wanted and not commands: + result = child_not_found("metadata.object.commands", "Команда", requested_command, object_card, base_id=base_id) + result.update( + { + "schema": "onec_object_commands.v1", + "source": {"kind": "live_metadata"}, + "query": {"command": requested_command, "include_storage": include_storage, "max_commands": max_commands}, + "commands": [], + "object_commands": [], + "form_commands": [], + "counts": { + "commands": 0, + "object_commands": 0, + "form_commands": 0, + "matched_common_commands": 0, + "scanned_common_commands": len(common_commands), + "source_missing": source_missing, + "undecodable": undecodable, + }, + } + ) + return result + return { + "schema": "onec_object_commands.v1", + "status": "ok", + "base_id": base_id, + "source": {"kind": "live_metadata"}, + "object": public_metadata_row(object_card, include_storage=include_storage), + "query": {"command": requested_command, "include_storage": include_storage, "max_commands": max_commands}, + "commands": commands, + "object_commands": commands, + "form_commands": [], + "counts": { + "commands": len(commands), + "object_commands": len(commands), + "form_commands": 0, + "matched_common_commands": total_matches, + "scanned_common_commands": len(common_commands), + "saved_overrides": saved_overrides, + "source_missing": source_missing, + "undecodable": undecodable, + "truncated": max(0, total_matches - len(commands)), + }, + "capabilities": { + "object_commands": True, + "form_commands": False, + "reverse_relation": "CommonCommand.group", + "coverage": "common_commands", + }, + "diagnostics": { + "note": "CommandGroup membership is a reverse relation from CommonCommand.Group; the group payload has no child command collection.", + }, + } + + def metadata_object_commands(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "metadata.object.commands") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": @@ -32761,6 +36317,42 @@ def metadata_object_commands(payload: dict[str, Any]) -> dict[str, Any]: ) return cached_public object_kind = str(object_card.get("kind") or payload.get("kind") or "") + if object_kind == "CommandGroup": + command_group_result = metadata_command_group_commands( + base_id=base_id, + object_card=object_card, + requested_command=requested_command, + include_storage=include_storage, + table=table, + max_commands=int(max_commands or 200), + timeout_seconds=timeout_seconds, + ) + command_group_counts = command_group_result.get("counts") if isinstance(command_group_result.get("counts"), dict) else {} + if ( + config + and object_guid + and not include_storage + and not wanted + and command_group_result.get("status") == "ok" + and int(command_group_counts.get("truncated") or 0) == 0 + ): + metadata_guid_index_upsert( + config, + { + "guid": object_guid, + "guid_role": cache_role, + "kind": object_card.get("kind"), + "kind_ru": object_card.get("kind_ru"), + "public_kind": object_card.get("public_kind"), + "name": object_card.get("name"), + "synonym": object_card.get("synonym"), + "presentation": ".".join(part for part in [object_card.get("kind_ru"), object_card.get("name")] if part), + "payload": command_group_result, + "source_file": object_guid, + }, + ) + command_group_result["cache"] = {"status": "stored", "role": cache_role} + return command_group_result if object_kind not in RELATED_SECTION_RULES: result = { "schema": "onec_object_commands.v1", @@ -33065,6 +36657,107 @@ def config_tree_identity_records(tree: Any) -> dict[str, dict[str, Any]]: return result +OBJECT_IDENTITY_PROPERTY_ALIASES = { + "synonym": "synonym", + "synonyms": "synonym", + "синоним": "synonym", + "синонимы": "synonym", + "comment": "comment", + "комментарий": "comment", +} + + +def normalize_object_identity_property(value: Any) -> str | None: + return OBJECT_IDENTITY_PROPERTY_ALIASES.get(str(value or "").strip().casefold()) + + +def config_tree_identity_property_target( + tree: Any, + object_guid: str, + property_name: str, + *, + language: str = "ru", +) -> dict[str, Any]: + """Resolve a supported identity scalar to an exact preserve-format tree path.""" + normalized_guid = str(object_guid or "").strip().lower() + record = config_tree_identity_records(tree).get(normalized_guid) + if not record: + return {"status": "not_found", "error": "object_identity_not_found"} + try: + marker_path = tuple(int(part) for part in str(record.get("evidence_path") or "").split(".") if part != "") + except ValueError: + return {"status": "unsupported", "error": "invalid_identity_evidence_path", "object": record} + if not marker_path: + return {"status": "unsupported", "error": "invalid_identity_evidence_path", "object": record} + parent_path = marker_path[:-1] + marker_index = marker_path[-1] + parent = config_tree_item_at_path(tree, parent_path) + siblings = config_tree_list_items(parent) + + normalized_property = normalize_object_identity_property(property_name) + if normalized_property == "synonym": + synonyms_index = marker_index + 2 + if synonyms_index >= len(siblings): + return {"status": "unsupported", "error": "synonym_container_missing", "object": record} + synonym_items = config_tree_list_items(siblings[synonyms_index]) + requested_language = str(language or "ru").strip() + for index in range(1, len(synonym_items) - 1, 2): + if config_tree_scalar(synonym_items[index]) != requested_language: + continue + value_node = synonym_items[index + 1] + node_type = str(value_node.get("type") or "") if isinstance(value_node, dict) else "" + if node_type not in {"atom", "string"}: + return {"status": "unsupported", "error": "synonym_value_is_not_scalar", "object": record} + path = (*parent_path, synonyms_index, index + 1) + return { + "status": "ok", + "property": "synonym", + "language": requested_language, + "path": ".".join(str(part) for part in path), + "path_tuple": path, + "current": config_tree_scalar(value_node), + "node_type": node_type, + "object": record, + } + return { + "status": "unsupported", + "error": "synonym_language_missing", + "language": requested_language, + "available_languages": [ + config_tree_scalar(synonym_items[index]) + for index in range(1, len(synonym_items) - 1, 2) + if config_tree_scalar(synonym_items[index]) + ], + "object": record, + } + + if normalized_property == "comment": + comment_index = marker_index + 3 + if comment_index >= len(siblings): + return {"status": "unsupported", "error": "comment_scalar_missing", "object": record} + value_node = siblings[comment_index] + node_type = str(value_node.get("type") or "") if isinstance(value_node, dict) else "" + if node_type not in {"atom", "string"}: + return {"status": "unsupported", "error": "comment_value_is_not_scalar", "object": record} + path = (*parent_path, comment_index) + return { + "status": "ok", + "property": "comment", + "path": ".".join(str(part) for part in path), + "path_tuple": path, + "current": config_tree_scalar(value_node), + "node_type": node_type, + "object": record, + } + + return { + "status": "unsupported", + "error": "unsupported_object_identity_property", + "allowed_properties": sorted(set(OBJECT_IDENTITY_PROPERTY_ALIASES.values())), + "object": record, + } + + def nested_metadata_semantic_index( tree: Any, *, @@ -33114,6 +36807,150 @@ def nested_metadata_semantic_index( return result +NESTED_MEMBER_KIND_ALIASES = { + "attribute": "Attribute", + "attributes": "Attribute", + "атрибут": "Attribute", + "атрибуты": "Attribute", + "реквизит": "Attribute", + "реквизиты": "Attribute", + "column": "Attribute", + "колонка": "Attribute", + "tabularsection": "TabularSection", + "tabularsections": "TabularSection", + "табличнаячасть": "TabularSection", + "табличныечасти": "TabularSection", + "dimension": "Dimension", + "dimensions": "Dimension", + "измерение": "Dimension", + "измерения": "Dimension", + "resource": "Resource", + "resources": "Resource", + "ресурс": "Resource", + "ресурсы": "Resource", +} + + +def canonical_nested_member_kind(value: Any) -> str | None: + return NESTED_MEMBER_KIND_ALIASES.get(normalize(str(value or ""))) + + +def normalized_nested_member_path(parts: Iterable[Any]) -> list[str]: + result: list[str] = [] + for part in parts: + text = str(part or "").strip() + if not text: + continue + result.append(canonical_nested_member_kind(text) or text.casefold()) + return result + + +def config_tree_member_identity_resolve( + tree: Any, + *, + parent_guid: str, + parent_kind: str, + parent_name: str, + member_path: list[str] | None = None, + member_kind: str | None = None, + member_name: str | None = None, +) -> dict[str, Any]: + identities = config_tree_identity_records(tree) + normalized_parent_guid = str(parent_guid or "").strip().lower() + parent_ref = object_selector_ref(parent_kind, parent_name) or f"{parent_kind}.{parent_name}" + semantic = nested_metadata_semantic_index( + tree, + parent_kind=parent_kind, + parent_ref=parent_ref, + dbnames_records=[], + ) + requested_path = normalized_nested_member_path(member_path or []) + requested_kind = canonical_nested_member_kind(member_kind) + requested_name = str(member_name or (member_path or [""])[-1]).strip() + candidates: list[dict[str, Any]] = [] + for guid, identity in identities.items(): + if guid == normalized_parent_guid or str(identity.get("name") or "").casefold() != requested_name.casefold(): + continue + detail = semantic.get(guid) or {} + category = canonical_nested_member_kind(detail.get("category")) or detail.get("category") + if requested_kind and category != requested_kind: + continue + ref = str(detail.get("ref") or ".".join(part for part in [parent_ref, category, identity.get("name")] if part)) + ref_parts = [part for part in ref.split(".") if part][2:] + if requested_path and normalized_nested_member_path(ref_parts) != requested_path: + continue + candidates.append( + { + "guid": guid, + "kind": category, + "name": identity.get("name"), + "synonyms": identity.get("synonyms") or {}, + "ref": ref, + } + ) + if len(candidates) == 1: + return {"status": "ok", "member": candidates[0]} + return { + "status": "ambiguous" if len(candidates) > 1 else "not_found", + "error": "member_identity_ambiguous" if len(candidates) > 1 else "member_identity_not_found", + "selector": { + **({"member_path": member_path} if member_path else {}), + **({"member_kind": requested_kind} if requested_kind else {}), + "member_name": requested_name, + }, + "candidates": candidates, + } + + +def config_tree_declared_record_for_guid(tree: Any, guid: str) -> dict[str, Any]: + """Find the deepest declared child record that owns an exact identity GUID.""" + try: + from parser.child_records import declared_child_records + except Exception as exc: + return {"status": "error", "error": "child_record_decoder_unavailable", "diagnostics": {"message": str(exc)}} + requested = str(guid or "").strip().lower() + candidates: list[dict[str, Any]] = [] + + def walk(node: Any, path: str, depth: int) -> None: + if depth > 12: + return + try: + records = declared_child_records(node, path) + except Exception: + records = [] + for record in records: + identities = config_tree_identity_records(record.node) + if requested in identities: + candidates.append( + { + "record_path": record.path, + "parent_path": ".".join(record.path.split(".")[:-1]), + "node": record.node, + "identity": identities[requested], + } + ) + for index, child in enumerate(config_tree_list_items(node)): + walk(child, f"{path}.{index}" if path else str(index), depth + 1) + + walk(tree, "", 0) + if not candidates: + return {"status": "not_found", "error": "declared_member_record_not_found"} + max_depth = max(len(item["record_path"].split(".")) for item in candidates) + deepest = [item for item in candidates if len(item["record_path"].split(".")) == max_depth] + if len(deepest) != 1: + return { + "status": "ambiguous", + "error": "declared_member_record_ambiguous", + "candidates": [{key: item.get(key) for key in ("record_path", "parent_path")} for item in deepest], + } + return {"status": "ok", **deepest[0]} + + +def deterministic_member_guid(parent_guid: str, member_kind: str, member_name: str, *, scope: str | None = None) -> str: + seed = f"{member_kind}:{scope}:{member_name}" if scope else f"{member_kind}:{member_name}" + return str(uuid.uuid5(uuid.UUID(str(parent_guid)), seed)).lower() + + def nested_metadata_guid_references( base_id: str, guids: Iterable[str], @@ -35148,6 +38985,7 @@ def metadata_form_decode(payload: dict[str, Any]) -> dict[str, Any]: "limit": 20, "scan_limit": int(payload.get("scan_limit") or 5000), "timeout_seconds": timeout_seconds, + "include_storage": True, } ) if saved_state_lookup.get("status") == "ok": @@ -35469,7 +39307,14 @@ def metadata_form_owner_index_build(payload: dict[str, Any]) -> dict[str, Any]: if config_error: return public_error_result(config_error, include_storage=False, method=method) requested_kind = canonical_kind(str(payload.get("kind") or payload.get("object_type") or "")) or str(payload.get("kind") or payload.get("object_type") or "") or None - requested_form = str(payload.get("form") or payload.get("form_name") or payload.get("name") or payload.get("object_name") or "").strip() + requested_owner_name = str(payload.get("name") or payload.get("object_name") or "").strip() + explicit_form_name = str(payload.get("form") or payload.get("form_name") or "").strip() + requested_form = ( + explicit_form_name + or requested_owner_name + if requested_kind == "CommonForm" + else explicit_form_name + ) indexed: list[dict[str, Any]] = [] errors: list[dict[str, Any]] = [] @@ -35482,6 +39327,8 @@ def metadata_form_owner_index_build(payload: dict[str, Any]) -> dict[str, Any]: owner_kind=requested_kind or "CommonForm", form_name=requested_form or None, extension={"name": payload.get("extension")} if payload.get("extension") else None, + owner_name=requested_owner_name or None, + owner_guid=str(payload.get("guid") or payload.get("object_guid") or "") or None, timeout_seconds=int(timeout_seconds or 90), ) if entry: @@ -35493,7 +39340,7 @@ def metadata_form_owner_index_build(payload: dict[str, Any]) -> dict[str, Any]: "base_id": base_id, "extension": payload.get("extension"), "kind": requested_kind or "CommonForm", - "query": requested_form, + "query": requested_owner_name or requested_form, "include_storage": True, "limit": int(limit or 10), "scan_limit": int(scan_limit or 5000), @@ -35544,7 +39391,7 @@ def metadata_form_owner_index_build(payload: dict[str, Any]) -> dict[str, Any]: table=candidate_table, file_name=candidate_file, owner_kind=str(item.get("kind") or requested_kind or "CommonForm"), - form_name=str(item.get("name") or requested_form or ""), + form_name=requested_form or (str(item.get("name") or "") if str(item.get("kind") or requested_kind) == "CommonForm" else None), extension=extension, owner_name=str(item.get("name") or requested_form or ""), owner_guid=str(item.get("guid") or ""), @@ -35568,6 +39415,7 @@ def metadata_form_owner_index_build(payload: dict[str, Any]) -> dict[str, Any]: "query": { "extension": payload.get("extension"), "kind": requested_kind or "CommonForm", + "owner": requested_owner_name or None, "form": requested_form or None, "table": table or None, "file_name": file_name or None, @@ -35587,6 +39435,8 @@ def metadata_form_owner_index_build(payload: dict[str, Any]) -> dict[str, Any]: FORM_ELEMENT_WRITE_METHOD = "metadata.form.element.write" FORM_ELEMENT_WRITE_APPLY_METHOD = "metadata.form.element.write_apply" +OBJECT_PROPERTY_WRITE_METHOD = "metadata.object.property.write" +OBJECT_MEMBER_ADD_METHOD = "metadata.object.member.add" FORM_TARGET_MOVE_METHOD = "metadata.form.target.move" FORM_COMMAND_BUTTON_WRITE_METHOD = "metadata.form.command_button.write" FORM_COMMAND_BUTTON_VERIFY_METHOD = "metadata.form.command_button.verify" @@ -35633,6 +39483,8 @@ TECHNICAL_WRITE_METHODS = { } WRITE_HISTORY_RECORDED_METHODS = { METADATA_WRITE_METHOD, + OBJECT_PROPERTY_WRITE_METHOD, + OBJECT_MEMBER_ADD_METHOD, FORM_COMMAND_BUTTON_WRITE_METHOD, FORM_ELEMENT_WRITE_APPLY_METHOD, MODULE_WRITE_APPLY_METHOD, @@ -36510,12 +40362,30 @@ def saved_state_form_search_row( form["synonym"] = descriptor_identity.get("synonym") if descriptor_identity.get("guid") and not form.get("guid"): form["guid"] = descriptor_identity.get("guid") + owner = decoded.get("owner") if isinstance(decoded.get("owner"), dict) else {} + if not owner.get("name") and not bool(payload.get("include_storage")): + related_owner = saved_state_related_descriptor_identity( + base_id=base_id, + table=table, + file_name=file_name, + timeout_seconds=timeout_seconds, + ) + if related_owner and related_owner.get("name"): + owner = { + "status": "resolved", + "kind": related_owner.get("kind") or "Catalog", + "name": related_owner.get("name"), + "synonym": related_owner.get("synonym"), + "guid": related_owner.get("guid"), + "source": "saved_state_descriptor", + } return { "table": table, "file_name": file_name, **({"name": descriptor_identity.get("name")} if descriptor_identity and descriptor_identity.get("name") else {}), **({"synonym": descriptor_identity.get("synonym")} if descriptor_identity and descriptor_identity.get("synonym") else {}), "form": form, + **({"owner": owner} if owner else {}), "source": { **{key: source.get(key) for key in ("kind", "database", "table", "file_name") if source.get(key) is not None}, "table": table, @@ -36526,8 +40396,85 @@ def saved_state_form_search_row( } +def public_saved_state_form_search_row( + row: dict[str, Any], + *, + requested_owner_kind: str | None = None, +) -> dict[str, Any]: + owner = row.get("owner") if isinstance(row.get("owner"), dict) else {} + form = row.get("form") if isinstance(row.get("form"), dict) else {} + identity = form.get("identity") if isinstance(form.get("identity"), dict) else {} + owner_public = { + key: value + for key, value in owner.items() + if key in {"status", "kind", "name", "synonym", "source"} and value is not None + } + owner_ref = object_selector_ref(owner_public.get("kind"), owner_public.get("name")) + form_name = str(row.get("name") or form.get("name") or identity.get("name") or "").strip() + form_synonym = row.get("synonym") or form.get("synonym") or identity.get("synonym") + if not owner_ref and canonical_kind(str(requested_owner_kind or "")) == "CommonForm" and form_name: + owner_ref = object_selector_ref("CommonForm", form_name) + if owner_ref: + owner_public["ref"] = owner_ref + qualified_name = ".".join( + part + for part in [ + str(owner_public.get("name") or ""), + form_name if normalize(form_name) != normalize(owner_public.get("name")) else "", + ] + if part + ) + form_selector = { + **({"ref": owner_ref} if owner_ref else {}), + **({"form": form_name} if form_name else {}), + **({"qualified_name": qualified_name} if qualified_name else {}), + } + public_matches = [] + for match in row.get("matches") or []: + if not isinstance(match, dict): + continue + section = str(match.get("section") or "") + match_name = str(match.get("name") or "").strip() + child_key = "command" if section == "commands" else ("attribute" if section == "attributes" else "element") + writable_properties = [] + for prop in match.get("writable_properties") or []: + if not isinstance(prop, dict): + continue + writable_properties.append( + { + key: value + for key, value in prop.items() + if key in {"property", "presentation", "value", "value_type", "verification"} and value is not None + } + ) + public_matches.append( + { + **{key: match.get(key) for key in ("name", "title", "type_name", "section") if match.get(key) is not None}, + "selector": { + **form_selector, + **({child_key: match_name} if match_name else {}), + }, + **({"writable_properties": writable_properties} if writable_properties else {}), + } + ) + return { + **({"owner": owner_public} if owner_public else {}), + "form": { + **({"name": form_name} if form_name else {}), + **({"synonym": form_synonym} if form_synonym is not None else {}), + }, + **({"qualified_name": qualified_name, "display_name": qualified_name} if qualified_name else {}), + **({"selector": form_selector} if form_selector else {}), + "counts": row.get("counts") or {}, + "matches": public_matches, + } + + def metadata_saved_state_forms_search(payload: dict[str, Any]) -> dict[str, Any]: method = SAVED_STATE_FORMS_SEARCH_METHOD + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error @@ -36541,6 +40488,10 @@ def metadata_saved_state_forms_search(payload: dict[str, Any]) -> dict[str, Any] scan_limit, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=1000, minimum=1, maximum=5000) if scan_limit_error: return scan_limit_error + include_storage, include_storage_error = strict_include_storage(payload, method) + if include_storage_error: + return include_storage_error + include_storage = bool(include_storage) tables_arg = payload.get("tables") if tables_arg is None: tables = ["ConfigCASSave", "ConfigSave"] @@ -36562,6 +40513,14 @@ def metadata_saved_state_forms_search(payload: dict[str, Any]) -> dict[str, Any] prefix = str(payload.get("prefix") or payload.get("extension_guid") or "").strip() if extension_guid and not prefix: prefix = f"{extension_guid}__" + owner_kind = canonical_kind(str(first_non_empty_arg(payload, "kind", "object_type") or "")) + owner_name = str(first_non_empty_arg(payload, "name", "object_name") or "").strip() + owner_guid = str(first_non_empty_arg(payload, "guid", "object_guid") or "").strip().lower() + explicit_form_name = str(first_non_empty_arg(payload, "form", "form_name", "name_filter") or "").strip() + is_common_form_request = owner_kind == "CommonForm" + form_name = explicit_form_name or (owner_name if is_common_form_request else "") + form_guid = str(payload.get("form_guid") or "").strip().lower() + row_payload = {**payload, **({"form": form_name} if form_name else {})} rows = [] scanned = 0 for table in tables: @@ -36588,32 +40547,78 @@ def metadata_saved_state_forms_search(payload: dict[str, Any]) -> dict[str, Any] base_id=base_id, table=table, file_name=file_name, - payload=payload, + payload=row_payload, timeout_seconds=int(timeout_seconds or 60), ) if row: + form_info = row.get("form") if isinstance(row.get("form"), dict) else {} + identity = form_info.get("identity") if isinstance(form_info.get("identity"), dict) else {} + candidate_form_name = str(row.get("name") or form_info.get("name") or identity.get("name") or "").strip() + candidate_form_guid = str(form_info.get("guid") or identity.get("guid") or "").strip().lower() + item_owner = row.get("owner") if isinstance(row.get("owner"), dict) else {} + if form_name and (not candidate_form_name or normalize(candidate_form_name) != normalize(form_name)): + continue + if form_guid and candidate_form_guid != form_guid: + continue + if owner_name: + if is_common_form_request: + if item_owner.get("name") and normalize(item_owner.get("name")) != normalize(owner_name): + continue + if not item_owner.get("name") and normalize(candidate_form_name) != normalize(owner_name): + continue + elif normalize(item_owner.get("name")) != normalize(owner_name): + continue + if owner_kind and item_owner.get("kind"): + if canonical_kind(str(item_owner.get("kind") or "")) != owner_kind: + continue + elif owner_kind and not is_common_form_request: + continue + if owner_guid and str(item_owner.get("guid") or "").strip().lower() != owner_guid: + continue row["file"] = file_row rows.append(row) if len(rows) >= int(limit or 50): break if len(rows) >= int(limit or 50): break + public_rows = rows if include_storage else [ + public_saved_state_form_search_row(row, requested_owner_kind=owner_kind) + for row in rows + ] return { "schema": "onec_saved_state_form_search.v1", "status": "ok", "base_id": base_id, - "source": {"kind": "live_sql", "tables": tables}, + "source": {"kind": "live_sql", "tables": tables} if include_storage else {"kind": "saved_state"}, "query": { - "form": payload.get("form") or payload.get("form_name") or payload.get("name_filter"), + "owner": { + "kind": owner_kind or None, + "name": owner_name or None, + **( + {"ref": object_selector_ref(owner_kind, owner_name)} + if not include_storage and object_selector_ref(owner_kind, owner_name) + else {} + ), + **({"guid": owner_guid or None} if include_storage else {}), + }, + "form": form_name or None, "element": payload.get("element") or payload.get("command") or payload.get("attribute") or payload.get("element_name"), "query": payload.get("query") or payload.get("text"), - "prefix": prefix or None, "extension": extension_filter or None, "limit": int(limit or 50), "scan_limit": int(scan_limit or 1000), + "include_storage": include_storage, + **({"form_guid": form_guid or None, "prefix": prefix or None} if include_storage else {}), + }, + "forms": public_rows, + "counts": { + "forms": len(public_rows), + **({"scanned": scanned} if include_storage else {}), + "limit": int(limit or 50), + }, + "diagnostics": { + "note": "Storage identities and low-level form write coordinates are hidden unless include_storage=true." }, - "forms": rows, - "counts": {"forms": len(rows), "scanned": scanned, "limit": int(limit or 50)}, } @@ -37197,8 +41202,109 @@ def metadata_saved_state_modules_owner_guid_from_selector( } +def public_saved_state_modules_search_row(row: dict[str, Any]) -> dict[str, Any] | None: + owner = row.get("owner") if isinstance(row.get("owner"), dict) else {} + form = row.get("form") if isinstance(row.get("form"), dict) else {} + module = row.get("module") if isinstance(row.get("module"), dict) else {} + owner_public = { + key: value + for key, value in owner.items() + if key in {"status", "kind", "name", "synonym", "source"} and value is not None + } + owner_ref = object_selector_ref(owner_public.get("kind"), owner_public.get("name")) + if owner_ref: + owner_public["ref"] = owner_ref + form_public = { + key: value + for key, value in form.items() + if key in {"name", "synonym", "source"} and value is not None + } + module_public = { + key: value + for key, value in module.items() + if key in {"kind", "name"} and value is not None + } + qualified_name = str(row.get("qualified_name") or row.get("display_name") or "").strip() + selector = { + **({"ref": owner_ref} if owner_ref else {}), + **({"form": form_public.get("name")} if form_public.get("name") else {}), + **({"module": module_public.get("name")} if module_public.get("name") else {}), + **({"qualified_name": qualified_name} if qualified_name else {}), + } + public_streams = [] + for stream in row.get("streams") or []: + if not isinstance(stream, dict): + continue + match = stream.get("match") if isinstance(stream.get("match"), dict) else {} + query = str(match.get("query") or "").strip() + in_text = bool(match.get("in_text")) + in_name = bool(query and qualified_name and normalize(query) in normalize(qualified_name)) + # A public search must not return a hit that matched only an opaque SQL + # file name. Name matching is against the 1C qualified name instead. + if query and not in_text and not in_name: + continue + stream_owner = stream.get("owner") if isinstance(stream.get("owner"), dict) else owner + stream_form = stream.get("form") if isinstance(stream.get("form"), dict) else form + stream_module = stream.get("module") if isinstance(stream.get("module"), dict) else module + public_stream = { + "preview": stream.get("preview"), + **({"encoding_repaired": True} if stream.get("encoding_repaired") else {}), + **( + { + "owner": { + key: value + for key, value in stream_owner.items() + if key in {"status", "kind", "name", "synonym", "source"} and value is not None + } + } + if stream_owner + else {} + ), + **( + { + "form": { + key: value + for key, value in stream_form.items() + if key in {"name", "synonym", "source"} and value is not None + } + } + if stream_form + else {} + ), + **( + { + "module": { + key: value + for key, value in stream_module.items() + if key in {"kind", "name"} and value is not None + } + } + if stream_module + else {} + ), + **({"qualified_name": qualified_name, "display_name": qualified_name} if qualified_name else {}), + **({"selector": selector} if selector else {}), + "match": {"query": query or None, "in_text": in_text, "in_name": in_name}, + } + public_streams.append(public_stream) + if not public_streams: + return None + return { + **({"owner": owner_public} if owner_public else {}), + **({"form": form_public} if form_public else {}), + **({"module": module_public} if module_public else {}), + **({"qualified_name": qualified_name, "display_name": qualified_name} if qualified_name else {}), + **({"selector": selector} if selector else {}), + "streams": public_streams, + "counts": {"streams": len(public_streams)}, + } + + def metadata_saved_state_modules_search(payload: dict[str, Any]) -> dict[str, Any]: method = SAVED_STATE_MODULES_SEARCH_METHOD + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload payload = dict(payload) if "query" in payload and not str(payload.get("query") or "").strip(): payload["query"] = None @@ -37217,8 +41323,28 @@ def metadata_saved_state_modules_search(payload: dict[str, Any]) -> dict[str, An scan_limit, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=1000, minimum=1, maximum=5000) if scan_limit_error: return scan_limit_error + include_storage, include_storage_error = strict_include_storage(payload, method) + if include_storage_error: + return include_storage_error + include_storage = bool(include_storage) + layer = str(payload.get("layer") or "").strip() + layer_tables = { + "base_saved_state": ["ConfigSave"], + "extension_saved_state": ["ConfigCASSave"], + } + if layer and layer not in layer_tables: + return invalid_argument( + method, + "layer", + "Unsupported saved-state layer.", + allowed_values=sorted(layer_tables), + ) tables_arg = payload.get("tables") - if tables_arg is None: + if layer and tables_arg is not None: + return invalid_argument(method, "tables", "Pass layer or tables, not both.") + if layer: + tables = layer_tables[layer] + elif tables_arg is None: tables = ["ConfigCASSave", "ConfigSave"] elif isinstance(tables_arg, list) and all(isinstance(item, str) for item in tables_arg): tables = [item for item in tables_arg if item in FORM_ELEMENT_SAVED_STATE_TABLES] @@ -37260,6 +41386,7 @@ def metadata_saved_state_modules_search(payload: dict[str, Any]) -> dict[str, An "limit": int(payload.get("form_search_limit") or 20), "scan_limit": int(scan_limit or 1000), "timeout_seconds": int(timeout_seconds or 60), + "include_storage": True, } ) form_rows = [row for row in forms_search.get("forms") or [] if isinstance(row, dict)] @@ -37325,30 +41452,65 @@ def metadata_saved_state_modules_search(payload: dict[str, Any]) -> dict[str, An break if len(modules) >= int(limit or 50): break - return { + public_modules = modules if include_storage else [ + public_row + for row in modules + if (public_row := public_saved_state_modules_search_row(row)) is not None + ] + result = { "schema": "onec_saved_state_module_search.v1", "status": "ok", "base_id": base_id, - "source": {"kind": "live_sql", "tables": tables}, + "source": {"kind": "live_sql", "tables": tables} if include_storage else {"kind": "saved_state"}, "query": { "query": payload.get("query") or payload.get("text"), - "prefix": prefix or None, "object_type": payload.get("object_type") or payload.get("kind"), "object_name": payload.get("object_name") or payload.get("name"), "extension": extension_filter or None, - "file_name": file_name_filter or None, - "stream_index": payload.get("stream_index"), + "layer": layer or None, "limit": int(limit or 50), "scan_limit": int(scan_limit or 1000), + "include_storage": include_storage, + **({"prefix": prefix or None, "file_name": file_name_filter or None, "stream_index": payload.get("stream_index")} if include_storage else {}), + }, + "modules": public_modules, + "counts": { + "modules": len(public_modules), + **({"scanned": scanned} if include_storage else {}), + "limit": int(limit or 50), + }, + "diagnostics": { + "note": "Saved-state SQL coordinates and write handles are hidden unless include_storage=true." }, - "modules": modules, - "counts": {"modules": len(modules), "scanned": scanned, "limit": int(limit or 50)}, - **({"owner_resolution": owner_resolution} if owner_resolution else {}), } + if owner_resolution: + if include_storage: + result["owner_resolution"] = owner_resolution + else: + resolved_object = owner_resolution.get("object") if isinstance(owner_resolution.get("object"), dict) else {} + public_resolution = { + key: value + for key, value in owner_resolution.items() + if key in {"status", "method"} and value is not None + } + if resolved_object: + public_object = public_metadata_row( + { + key: value + for key, value in resolved_object.items() + if key in {"kind", "kind_ru", "public_kind", "name", "synonym", "source"} and value is not None + } + ) + public_resolution["object"] = public_object + result["selector_resolution"] = public_resolution + return result def metadata_form_write_target_resolve(payload: dict[str, Any]) -> dict[str, Any]: method = FORM_WRITE_TARGET_RESOLVE_METHOD + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error @@ -37359,6 +41521,37 @@ def metadata_form_write_target_resolve(payload: dict[str, Any]) -> dict[str, Any table = str(payload.get("table") or "ConfigCASSave") if table not in FORM_ELEMENT_SAVED_STATE_TABLES: return invalid_argument(method, "table", "Only saved-state tables may be resolved for writes.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES)) + owner_kind = canonical_kind(str(first_non_empty_arg(payload, "kind", "object_type") or "")) + owner_name = str(first_non_empty_arg(payload, "name", "object_name") or "").strip() + owner_guid = str(first_non_empty_arg(payload, "guid", "object_guid") or "").strip().lower() + explicit_form_name = str(first_non_empty_arg(payload, "form", "form_name", "name_filter") or "").strip() + is_common_form_request = owner_kind == "CommonForm" or ( + owner_kind in {None, "", "Form"} + and not explicit_form_name + and bool(owner_name) + ) + legacy_guid_is_form = bool( + payload.get("guid") + and not payload.get("object_guid") + and not owner_kind + and not owner_name + and not explicit_form_name + ) + form_name = explicit_form_name or (owner_name if is_common_form_request else "") + if owner_name and not is_common_form_request and not form_name: + return invalid_argument( + method, + "form_name", + "Pass form or form_name when resolving a target in a nested owner form.", + ) + form_guid = str( + first_non_empty_arg( + payload, + "form_guid", + *(("guid", "object_guid") if is_common_form_request else ("guid",) if legacy_guid_is_form else ()), + ) + or "" + ).strip().lower() decode_payload = { **without_form_decode_selector_keys(payload), "base_id": base_id, @@ -37368,30 +41561,44 @@ def metadata_form_write_target_resolve(payload: dict[str, Any]) -> dict[str, Any "max_items": int(payload.get("max_items") or 5000), "timeout_seconds": int(timeout_seconds or 60), } - if not (payload.get("file_name") or payload.get("form_guid") or payload.get("guid")): + if not is_common_form_request: + for owner_selector_key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid"): + decode_payload.pop(owner_selector_key, None) + if form_guid: + decode_payload["form_guid"] = form_guid + decode_payload.pop("guid", None) + if not (payload.get("file_name") or form_guid): search = metadata_saved_state_forms_search( { **payload, "base_id": base_id, "tables": [table], + **({"form": form_name} if form_name else {}), "limit": int(payload.get("search_limit") or 10), "scan_limit": int(payload.get("scan_limit") or 1000), "timeout_seconds": int(timeout_seconds or 60), + "include_storage": True, } ) forms = search.get("forms") or [] - requested_form_name = str(first_non_empty_arg(payload, "form", "form_name", "name_filter", "object_name", "name") or "").strip() - if requested_form_name and len(forms) > 1: - exact_forms = [] - for form_row in forms: - if not isinstance(form_row, dict): + exact_forms = [] + for form_row in forms: + if not isinstance(form_row, dict): + continue + form_info = form_row.get("form") if isinstance(form_row.get("form"), dict) else {} + candidate_name = str(form_row.get("name") or form_info.get("name") or "").strip() + if form_name and (not candidate_name or normalize(candidate_name) != normalize(form_name)): + continue + item_owner = form_row.get("owner") if isinstance(form_row.get("owner"), dict) else {} + if owner_name and not is_common_form_request: + if normalize(item_owner.get("name")) != normalize(owner_name): continue - form_info = form_row.get("form") if isinstance(form_row.get("form"), dict) else {} - candidate_name = str(form_row.get("name") or form_info.get("name") or "").strip() - if candidate_name and normalize(candidate_name) == normalize(requested_form_name): - exact_forms.append(form_row) - if len(exact_forms) == 1: - forms = exact_forms + if owner_kind and canonical_kind(str(item_owner.get("kind") or "")) != owner_kind: + continue + if owner_guid and str(item_owner.get("guid") or "").strip().lower() != owner_guid: + continue + exact_forms.append(form_row) + forms = exact_forms if len(forms) == 1: form_row = forms[0] if isinstance(forms[0], dict) else {} form_source = form_row.get("source") if isinstance(form_row.get("source"), dict) else {} @@ -37404,7 +41611,11 @@ def metadata_form_write_target_resolve(payload: dict[str, Any]) -> dict[str, Any "schema": "onec_form_write_target_resolution.v1", "status": "not_found", "base_id": base_id, - "query": {key: payload.get(key) for key in ("table", "form", "element", "command", "property", "query") if payload.get(key) is not None}, + "query": { + "owner": {"kind": owner_kind or None, "name": owner_name or None, "guid": owner_guid or None}, + "form": form_name or None, + **{key: payload.get(key) for key in ("table", "element", "command", "property", "query") if payload.get(key) is not None}, + }, "diagnostics": {"message": "Saved-state form was not found. Pass file_name/form_guid or broaden search_limit/scan_limit."}, "search": search, } @@ -37413,10 +41624,14 @@ def metadata_form_write_target_resolve(payload: dict[str, Any]) -> dict[str, Any "schema": "onec_form_write_target_resolution.v1", "status": "ambiguous", "base_id": base_id, - "query": {key: payload.get(key) for key in ("table", "form", "element", "command", "property", "query") if payload.get(key) is not None}, + "query": { + "owner": {"kind": owner_kind or None, "name": owner_name or None, "guid": owner_guid or None}, + "form": form_name or None, + **{key: payload.get(key) for key in ("table", "element", "command", "property", "query") if payload.get(key) is not None}, + }, "candidates": forms[:10], "counts": {"forms": len(forms)}, - "diagnostics": {"message": "More than one saved-state form matched. Pass file_name or a narrower selector."}, + "diagnostics": {"message": "More than one saved-state form matched the owner/form selector. Pass form_guid or file_name."}, } decoded = metadata_form_decode(decode_payload) if decoded.get("status") != "ok": @@ -37526,6 +41741,9 @@ def metadata_form_write_target_resolve(payload: dict[str, Any]) -> dict[str, Any def metadata_form_write_target_verify(payload: dict[str, Any]) -> dict[str, Any]: method = FORM_WRITE_TARGET_VERIFY_METHOD + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error @@ -37560,7 +41778,11 @@ def metadata_form_write_target_verify(payload: dict[str, Any]) -> dict[str, Any] "writable_now": True, "needs_prepare": False, "base_id": base_id, - "source": source if include_storage else {"table": source.get("table") or table}, + "source": ( + source + if include_storage + else {"kind": "saved_state", "layer": SAVED_STATE_LAYER_BY_TABLE[source.get("table") or table]} + ), "query": query, "form": resolved.get("form"), "target": resolved.get("target"), @@ -37595,12 +41817,21 @@ def metadata_form_write_target_verify(payload: dict[str, Any]) -> dict[str, Any] "writable_now": False, "needs_prepare": resolved.get("status") == "not_found", "base_id": base_id, - "source": {"table": target_table}, + "source": ( + {"table": target_table} + if include_storage + else {"kind": "saved_state", "layer": SAVED_STATE_LAYER_BY_TABLE[target_table]} + ), "query": query, "resolve_status": resolved.get("status"), "diagnostics": resolved.get("diagnostics"), "counts": resolved.get("counts"), - "next_resolution": {"method": "metadata.saved_state.prepare", "payload": prepare_payload} if resolved.get("status") == "not_found" else None, + "next_resolution": { + "method": "metadata.saved_state.prepare", + "params": public_saved_state_prepare_call_payload(prepare_payload), + } + if resolved.get("status") == "not_found" + else None, } if search: result["saved_state_search"] = compact_saved_state_form_search_result(search, selected_form=selected_form, include_storage=bool(include_storage)) @@ -37610,6 +41841,9 @@ def metadata_form_write_target_verify(payload: dict[str, Any]) -> dict[str, Any] def metadata_form_element_write(payload: dict[str, Any]) -> dict[str, Any]: + payload = normalize_object_selector_aliases(payload, FORM_ELEMENT_WRITE_METHOD) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload base_id_or_error = require_base_id(payload, FORM_ELEMENT_WRITE_METHOD) if isinstance(base_id_or_error, dict): return base_id_or_error @@ -37637,7 +41871,34 @@ def metadata_form_element_write(payload: dict[str, Any]) -> dict[str, Any]: "Saved-state element write planning only targets saved-state tables.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES), ) - element_error = validate_optional_string_arguments(payload, FORM_ELEMENT_WRITE_METHOD, ["element", "element_name", "element_path", "path", "element_id", "id", "file_name", "form_guid", "guid", "form", "kind", "name"]) + element_error = validate_optional_string_arguments( + payload, + FORM_ELEMENT_WRITE_METHOD, + [ + "ref", + "kind", + "name", + "guid", + "object_type", + "object_name", + "object_guid", + "element", + "element_name", + "element_path", + "path", + "element_id", + "id", + "file_name", + "form_guid", + "form", + "form_name", + "name_filter", + "command", + "attribute", + "extension", + "extension_guid", + ], + ) if element_error: return element_error edits = payload.get("edits") @@ -37647,8 +41908,31 @@ def metadata_form_element_write(payload: dict[str, Any]) -> dict[str, Any]: return invalid_argument(FORM_ELEMENT_WRITE_METHOD, "edits", "Pass edits as a non-empty JSON array of {property, value, expected_old?}.") working_payload = dict(payload) + owner_kind = canonical_kind(str(first_non_empty_arg(working_payload, "kind", "object_type") or "")) + owner_name = str(first_non_empty_arg(working_payload, "name", "object_name") or "").strip() + explicit_form_name = str(first_non_empty_arg(working_payload, "form", "form_name", "name_filter") or "").strip() + is_common_form_request = owner_kind == "CommonForm" or ( + owner_kind in {None, "", "Form"} + and not explicit_form_name + and bool(owner_name) + ) + legacy_guid_is_form = bool( + working_payload.get("guid") + and not working_payload.get("object_guid") + and not owner_kind + and not owner_name + and not explicit_form_name + ) + concrete_form_guid = str( + first_non_empty_arg( + working_payload, + "form_guid", + *(("guid", "object_guid") if is_common_form_request else ("guid",) if legacy_guid_is_form else ()), + ) + or "" + ).strip() resolver_result = None - if not (working_payload.get("file_name") or working_payload.get("form_guid") or working_payload.get("guid")): + if not (working_payload.get("file_name") or concrete_form_guid): first_edit = edits[0] if isinstance(edits[0], dict) else {} resolver_result = metadata_form_write_target_resolve( { @@ -37673,9 +41957,16 @@ def metadata_form_element_write(payload: dict[str, Any]) -> dict[str, Any]: result["method"] = FORM_ELEMENT_WRITE_METHOD return result + decode_payload = without_form_decode_selector_keys(working_payload) + if working_payload.get("file_name"): + for owner_selector_key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid"): + decode_payload.pop(owner_selector_key, None) + if concrete_form_guid: + decode_payload["form_guid"] = concrete_form_guid + decode_payload.pop("guid", None) decoded = metadata_form_decode( { - **without_form_decode_selector_keys(working_payload), + **decode_payload, "base_id": base_id, "table": table, "include_storage": True, @@ -37839,8 +42130,711 @@ def sanitize_proposal_for_response(proposal: Any) -> Any: return sanitized +def sanitize_object_property_write_result(result: dict[str, Any]) -> dict[str, Any]: + sanitized = sanitize_public_result(result) + proposal = sanitized.get("proposal") if isinstance(sanitized.get("proposal"), dict) else None + if proposal is not None: + proposal.pop("source", None) + for key in ("apply_result", "rollback_result"): + nested = sanitized.get(key) if isinstance(sanitized.get(key), dict) else None + if nested is not None: + nested.pop("source", None) + return sanitized + + +def normalize_object_property_member_payload(payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]: + method = OBJECT_PROPERTY_WRITE_METHOD + result = dict(payload) + member_ref = str( + first_non_empty_arg(payload, "member_ref", "child_ref", "canonical_path") or "" + ).strip() + member_path: list[str] = [] + if member_ref: + parsed = parse_1c_object_path(member_ref) + member_path = list(parsed.get("member_path") or []) + if not parsed.get("kind") or not parsed.get("name") or not member_path: + return payload, invalid_argument( + method, + "member_ref", + "member_ref must be a full public child path such as Catalog.Номенклатура.Attribute.Артикул.", + ) + existing_kind = canonical_kind(str(result.get("kind") or result.get("object_type") or "")) + existing_name = str(result.get("name") or result.get("object_name") or "").strip() + if existing_kind and existing_kind != parsed.get("kind"): + return payload, invalid_argument(method, "member_ref", "member_ref object kind conflicts with the parent object selector.") + if existing_name and existing_name != parsed.get("name"): + return payload, invalid_argument(method, "member_ref", "member_ref object name conflicts with the parent object selector.") + result["kind"] = parsed.get("kind") + result["name"] = parsed.get("name") + result["ref"] = parsed.get("canonical_ref") + result["_member_path"] = member_path + result.setdefault("member_name", member_path[-1]) + if len(member_path) >= 2 and canonical_nested_member_kind(member_path[-2]): + result.setdefault("member_kind", canonical_nested_member_kind(member_path[-2])) + member_name = str(result.get("member_name") or "").strip() + member_kind = str(result.get("member_kind") or "").strip() + if member_kind and not canonical_nested_member_kind(member_kind): + return payload, invalid_argument( + method, + "member_kind", + "Unsupported child category.", + allowed_values=["Attribute", "TabularSection", "Dimension", "Resource"], + ) + if member_kind and not member_name: + return payload, invalid_argument(method, "member_name", "member_name is required when member_kind is provided.") + result["_member_requested"] = bool(member_name or member_path) + return result, None + + +def metadata_object_property_write(payload: dict[str, Any]) -> dict[str, Any]: + method = OBJECT_PROPERTY_WRITE_METHOD + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload + payload, member_error = normalize_object_property_member_payload(payload) + if member_error: + return member_error + payload, context_error = normalize_repository_write_context(payload, method) + if context_error: + return {"schema": "onec_metadata_object_property_write.v1", "method": method, **context_error} + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() + if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: + return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) + repository_error = repository_apply_gate(payload, method, mode) + if repository_error: + return repository_error + include_storage, include_storage_error = strict_include_storage(payload, method) + if include_storage_error: + return include_storage_error + include_storage = bool(include_storage) + timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) + if timeout_error: + return timeout_error + timeout_seconds = int(timeout_seconds or 60) + allow_write, allow_write_error = strict_bool_argument(payload, "allow_saved_state_write", method=method, default=False) + if allow_write_error: + return allow_write_error + if not allow_write: + return invalid_argument(method, "allow_saved_state_write", "Saved-state object property planning is opt-in; pass allow_saved_state_write=true.") + auto_prepare, auto_prepare_error = strict_bool_argument(payload, "auto_prepare_saved_state", method=method, default=False) + if auto_prepare_error: + return auto_prepare_error + + requested_property_raw = payload.get("property") + requested_property = normalize_object_identity_property(requested_property_raw) + if not requested_property: + raw_name = str(requested_property_raw or "").strip().casefold() + if raw_name in {"name", "имя", "rename", "переименование"}: + return invalid_argument( + method, + "property", + "Object rename is intentionally disabled because reference-safe metadata rename rules are not yet proven.", + allowed_values=["synonym", "comment"], + ) + return invalid_argument(method, "property", "Only synonym and comment are supported.", allowed_values=["synonym", "comment"]) + if "value" not in payload or not isinstance(payload.get("value"), str): + return invalid_argument(method, "value", "value is required and must be a JSON string; an empty string is allowed.") + requested_value = str(payload.get("value")) + language = str(payload.get("language") or "ru").strip() + if requested_property == "synonym" and not language: + return invalid_argument(method, "language", "language must be a non-empty locale already present in the synonym container.") + + extension = str(payload.get("extension") or "").strip() + layer = str(payload.get("layer") or "").strip() or ("extension_saved_state" if extension else "base_saved_state") + if layer not in SAVED_STATE_TABLE_BY_LAYER: + return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) + if extension and layer != "extension_saved_state": + return invalid_argument(method, "layer", "An extension selector requires extension_saved_state.", allowed_values=["extension_saved_state"]) + target_table = SAVED_STATE_TABLE_BY_LAYER[layer] + prepare_selector = { + key: payload[key] + for key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "extension") + if payload.get(key) not in {None, ""} + } + prepare_payload = { + **repository_write_context(payload), + "base_id": base_id, + "layer": layer, + **prepare_selector, + "mode": "plan", + "include_storage": True, + "timeout_seconds": timeout_seconds, + } + prepare_result = metadata_saved_state_prepare(prepare_payload) + prepare_status = str(prepare_result.get("status") or "error") + if prepare_status == "plan_ready": + if mode == "plan" or not auto_prepare: + next_params = public_saved_state_prepare_call_payload(prepare_payload) + return { + "schema": "onec_metadata_object_property_write.v1", + "method": method, + "status": "needs_prepare", + "execution_mode": mode, + "base_id": base_id, + "layer": layer, + "property": {"name": requested_property, **({"language": language} if requested_property == "synonym" else {}), "requested": requested_value}, + "object": public_saved_state_prepare_object(prepare_result.get("object")), + "prepare_plan": public_saved_state_prepare_embedded_result(prepare_result, prepare_payload), + "next_call": {"method": "metadata.saved_state.prepare", "params": next_params}, + "diagnostics": {"message": "The named object has no saved-state working copy yet. Prepare it before planning the scalar edit."}, + } + allow_prepare, allow_prepare_error = strict_bool_argument(payload, "allow_sql_saved_state_prepare", method=method, default=False) + if allow_prepare_error: + return allow_prepare_error + if not allow_prepare: + return invalid_argument(method, "allow_sql_saved_state_prepare", "Automatic saved-state preparation is opt-in; pass allow_sql_saved_state_prepare=true.") + prepare_result = metadata_saved_state_prepare( + { + **prepare_payload, + "mode": "apply_and_verify", + "allow_sql_saved_state_prepare": True, + } + ) + prepare_status = str(prepare_result.get("status") or "error") + if prepare_status not in {"blocked_target_collision", "verified", "applied"}: + result = { + "schema": "onec_metadata_object_property_write.v1", + "method": method, + "status": prepare_status, + "execution_mode": mode, + "base_id": base_id, + "layer": layer, + "error": prepare_result.get("error") or "saved_state_not_ready", + "prepare_result": prepare_result, + "diagnostics": prepare_result.get("diagnostics") or {"message": "Could not resolve a writable saved-state copy for the named object."}, + } + return result if include_storage else sanitize_object_property_write_result(result) + + object_card = prepare_result.get("object") if isinstance(prepare_result.get("object"), dict) else {} + object_guid = str(object_card.get("guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower() + if not is_guid_text(object_guid): + result = { + "schema": "onec_metadata_object_property_write.v1", + "method": method, + "status": "not_found", + "error": "resolved_object_guid_missing", + "base_id": base_id, + "object": public_saved_state_prepare_object(object_card), + "diagnostics": {"message": "The name-first object resolver did not return an exact metadata GUID."}, + } + return result if include_storage else sanitize_object_property_write_result(result) + file_names = [str(item) for item in (prepare_result.get("file_names") or []) if isinstance(item, str) and Path(item).name == item] + matches: list[dict[str, Any]] = [] + member_errors: list[dict[str, Any]] = [] + property_errors: list[dict[str, Any]] = [] + read_errors: list[dict[str, Any]] = [] + for file_name in file_names: + data, _config, read_error = read_storage_file_bytes(base_id, target_table, file_name, timeout_seconds=timeout_seconds) + if read_error: + read_errors.append({"file_name": file_name, "error": read_error}) + continue + tree = parse_config_tree_from_bytes(data or b"") + target_guid = object_guid + member: dict[str, Any] | None = None + if payload.get("_member_requested"): + member_resolution = config_tree_member_identity_resolve( + tree, + parent_guid=object_guid, + parent_kind=str(object_card.get("kind") or payload.get("kind") or ""), + parent_name=str(object_card.get("name") or payload.get("name") or ""), + member_path=list(payload.get("_member_path") or []), + member_kind=str(payload.get("member_kind") or "") or None, + member_name=str(payload.get("member_name") or "") or None, + ) + if member_resolution.get("status") != "ok": + member_errors.append({"file_name": file_name, **member_resolution}) + continue + member = member_resolution.get("member") + target_guid = str((member or {}).get("guid") or "") + target = config_tree_identity_property_target(tree, target_guid, requested_property, language=language) + if target.get("status") == "ok": + matches.append({"file_name": file_name, "data": data, "tree": tree, "target": target, "target_guid": target_guid, "member": member}) + elif target.get("error") != "object_identity_not_found": + property_errors.append({"file_name": file_name, "target": target}) + if len(matches) != 1: + member_ambiguous = any(item.get("status") == "ambiguous" for item in member_errors) + status = "ambiguous" if len(matches) > 1 or member_ambiguous else ("unsupported" if property_errors else "not_found") + result = { + "schema": "onec_metadata_object_property_write.v1", + "method": method, + "status": status, + "error": "ambiguous_identity_property_target" if len(matches) > 1 else ( + "member_identity_ambiguous" if member_ambiguous else + (property_errors[0].get("target") or {}).get("error") if property_errors else "identity_property_target_not_found" + ), + "base_id": base_id, + "layer": layer, + "object": { + **{key: object_card.get(key) for key in ("kind", "name", "synonym", "guid") if object_card.get(key) is not None}, + **({"ref": object_selector_ref(object_card.get("kind"), object_card.get("name"))} if object_selector_ref(object_card.get("kind"), object_card.get("name")) else {}), + }, + "property": {"name": requested_property, **({"language": language} if requested_property == "synonym" else {})}, + "counts": {"matches": len(matches), "candidate_files": len(file_names), "read_errors": len(read_errors)}, + "diagnostics": { + "message": "The exact object GUID and scalar identity property must resolve in exactly one saved-state payload.", + "property_errors": property_errors, + "member_errors": member_errors, + "read_errors": read_errors, + }, + } + return result if include_storage else sanitize_object_property_write_result(result) + + resolved = matches[0] + file_name = str(resolved["file_name"]) + target = resolved["target"] + resolved_member = resolved.get("member") if isinstance(resolved.get("member"), dict) else None + target_guid = str(resolved.get("target_guid") or object_guid) + current_value = str(target.get("current") or "") + if "expected_old" in payload and payload.get("expected_old") != current_value: + result = { + "schema": "onec_metadata_object_property_write.v1", + "method": method, + "status": "precondition_failed", + "error": "expected_old_mismatch", + "base_id": base_id, + "object": {key: object_card.get(key) for key in ("kind", "name", "guid") if object_card.get(key) is not None}, + **({"member": resolved_member} if resolved_member else {}), + "property": {"name": requested_property, "current": current_value, "expected_old": payload.get("expected_old"), "requested": requested_value}, + "diagnostics": {"message": "The saved-state scalar changed after the caller read it."}, + } + return result if include_storage else sanitize_object_property_write_result(result) + result: dict[str, Any] = { + "schema": "onec_metadata_object_property_write.v1", + "method": method, + "status": "unchanged" if current_value == requested_value else "planned", + "execution_mode": mode, + "base_id": base_id, + "layer": layer, + "object": { + **{key: object_card.get(key) for key in ("kind", "name", "synonym", "guid") if object_card.get(key) is not None}, + **({"ref": object_selector_ref(object_card.get("kind"), object_card.get("name"))} if object_selector_ref(object_card.get("kind"), object_card.get("name")) else {}), + }, + **({"member": resolved_member} if resolved_member else {}), + "property": { + "name": requested_property, + **({"language": language} if requested_property == "synonym" else {}), + "current": current_value, + "requested": requested_value, + }, + "write_mode": {"target": "saved_state", "active_configuration_write": False, "sql_write_performed": False}, + } + if current_value == requested_value: + return result if include_storage else sanitize_object_property_write_result(result) + + proposal = changes_propose( + { + "base_id": base_id, + "source": { + "base_id": base_id, + "table": target_table, + "file_name": file_name, + **({"expected_sha1": payload.get("expected_sha1")} if payload.get("expected_sha1") else {}), + }, + "edits": [ + { + "path": target.get("path"), + "value": requested_value, + "node_type": target.get("node_type"), + "expected_old": current_value, + "property": requested_property, + } + ], + "include_payload": mode != "plan", + "preserve_format": True, + "timeout_seconds": timeout_seconds, + "summary": payload.get("summary") or f"Saved-state object {requested_property} edit", + } + ) + result["proposal"] = proposal + if proposal.get("status") not in {"accepted_for_review", "ok"} or (proposal.get("validation") or {}).get("status") != "ok": + result["status"] = proposal.get("status") if proposal.get("status") not in {"accepted_for_review", "ok"} else "proposal_validation_failed" + return result if include_storage else sanitize_object_property_write_result(result) + if mode == "plan": + result["proposal"] = sanitize_proposal_for_response(proposal) + return result if include_storage else sanitize_object_property_write_result(result) + + allow_apply, allow_apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) + if allow_apply_error: + return allow_apply_error + if not allow_apply: + return invalid_argument(method, "allow_sql_saved_state_apply", "Apply mode is opt-in; pass allow_sql_saved_state_apply=true.") + apply_result = storage_saved_state_apply_proposal( + { + **repository_write_context(payload), + "base_id": base_id, + "allow_sql_saved_state_apply": True, + "proposal": proposal, + "timeout_seconds": timeout_seconds, + } + ) + result["proposal"] = sanitize_proposal_for_response(proposal) + result["apply_result"] = apply_result + result["applied"] = bool(apply_result.get("applied")) + result["status"] = str(apply_result.get("status") or "error") + result["write_mode"]["sql_write_performed"] = result["applied"] + if not result["applied"] or mode == "apply": + return result if include_storage else sanitize_object_property_write_result(result) + + readback_data, _readback_config, readback_error = read_storage_file_bytes(base_id, target_table, file_name, timeout_seconds=timeout_seconds) + readback_target = ( + config_tree_identity_property_target(parse_config_tree_from_bytes(readback_data or b""), target_guid, requested_property, language=language) + if not readback_error + else {"status": "error", "error": "readback_failed"} + ) + verified = readback_target.get("status") == "ok" and readback_target.get("current") == requested_value + result["semantic_verification"] = { + "status": "ok" if verified else "mismatch", + "expected": requested_value, + "actual": readback_target.get("current"), + **({"error": readback_target.get("error")} if readback_target.get("error") else {}), + } + if mode == "apply_and_verify": + result["status"] = "verified" if verified else "verification_failed" + return result if include_storage else sanitize_object_property_write_result(result) + + allow_rollback, allow_rollback_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) + if allow_rollback_error: + return allow_rollback_error + if not allow_rollback: + return invalid_argument(method, "allow_sql_saved_state_rollback", "apply_and_rollback mode is opt-in; pass allow_sql_saved_state_rollback=true.") + backup_id = ((apply_result.get("backup") or {}).get("backup_id") if isinstance(apply_result.get("backup"), dict) else None) + if not backup_id: + result["status"] = "rollback_unavailable" + result["diagnostics"] = {"message": "Apply result did not return backup.backup_id; cannot rollback automatically."} + return result if include_storage else sanitize_object_property_write_result(result) + rollback_result = storage_saved_state_rollback( + { + **repository_write_context(payload), + "base_id": base_id, + "allow_sql_saved_state_rollback": True, + "backup_id": backup_id, + "timeout_seconds": timeout_seconds, + } + ) + result["rollback_result"] = rollback_result + result["rolled_back"] = bool(rollback_result.get("applied")) + result["status"] = ( + "verified_and_rolled_back" + if result["applied"] and verified and result["rolled_back"] + else ("applied_rollback_failed" if result["applied"] else result["status"]) + ) + return result if include_storage else sanitize_object_property_write_result(result) + + +def metadata_object_member_add(payload: dict[str, Any]) -> dict[str, Any]: + method = OBJECT_MEMBER_ADD_METHOD + template_ref = str(payload.get("template_member_ref") or "").strip() + normalized, selector_error = normalize_object_property_member_payload({**payload, "member_ref": template_ref}) + if selector_error: + return selector_error + payload = normalized + if canonical_nested_member_kind(payload.get("member_kind")) != "Attribute": + return invalid_argument(method, "template_member_ref", "The first structural writer supports only an existing Attribute template.") + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + payload, context_error = normalize_repository_write_context(payload, method) + if context_error: + return {"schema": "onec_metadata_object_member_add.v1", "method": method, **context_error} + mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() + if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: + return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) + repository_error = repository_apply_gate(payload, method, mode) + if repository_error: + return repository_error + include_storage, include_storage_error = strict_include_storage(payload, method) + if include_storage_error: + return include_storage_error + include_storage = bool(include_storage) + timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) + if timeout_error: + return timeout_error + timeout_seconds = int(timeout_seconds or 60) + allow_write, allow_write_error = strict_bool_argument(payload, "allow_saved_state_write", method=method, default=False) + if allow_write_error: + return allow_write_error + if not allow_write: + return invalid_argument(method, "allow_saved_state_write", "Attribute add planning is opt-in; pass allow_saved_state_write=true.") + auto_prepare, auto_prepare_error = strict_bool_argument(payload, "auto_prepare_saved_state", method=method, default=False) + if auto_prepare_error: + return auto_prepare_error + new_name = str(payload.get("new_member_name") or "").strip() + if not new_name or not re.fullmatch(r"[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]*", new_name): + return invalid_argument(method, "new_member_name", "new_member_name must be a valid non-empty 1C identifier.") + new_synonym = str(payload.get("new_member_synonym") if payload.get("new_member_synonym") is not None else new_name) + template_parts = [part for part in template_ref.split(".") if part] + if len(template_parts) < 4 or canonical_nested_member_kind(template_parts[-2]) != "Attribute": + return invalid_argument(method, "template_member_ref", "template_member_ref must end with Attribute..") + container_ref = ".".join(template_parts[:-2]) + requested_ref = ".".join([container_ref, "Attribute", new_name]) + container_scope = "tabular_section" if "TabularSection" in normalized_nested_member_path(payload.get("_member_path") or []) else "object" + + extension = str(payload.get("extension") or "").strip() + layer = str(payload.get("layer") or "").strip() or ("extension_saved_state" if extension else "base_saved_state") + if layer not in SAVED_STATE_TABLE_BY_LAYER: + return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) + if extension and layer != "extension_saved_state": + return invalid_argument(method, "layer", "An extension selector requires extension_saved_state.", allowed_values=["extension_saved_state"]) + target_table = SAVED_STATE_TABLE_BY_LAYER[layer] + prepare_payload = { + **repository_write_context(payload), + "base_id": base_id, + "layer": layer, + **{ + key: payload[key] + for key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "extension") + if payload.get(key) not in {None, ""} + }, + "mode": "plan", + "include_storage": True, + "timeout_seconds": timeout_seconds, + } + prepare_result = metadata_saved_state_prepare(prepare_payload) + prepare_status = str(prepare_result.get("status") or "error") + if prepare_status == "plan_ready": + if mode == "plan" or not auto_prepare: + result = { + "schema": "onec_metadata_object_member_add.v1", + "method": method, + "status": "needs_prepare", + "execution_mode": mode, + "base_id": base_id, + "layer": layer, + "object": public_saved_state_prepare_object(prepare_result.get("object")), + "template": {"ref": template_ref, "kind": "Attribute", "name": payload.get("member_name")}, + "container": {"ref": container_ref, "scope": container_scope}, + "requested_member": {"kind": "Attribute", "name": new_name, "synonym": new_synonym, "ref": requested_ref}, + "prepare_plan": public_saved_state_prepare_embedded_result(prepare_result, prepare_payload), + "next_call": {"method": "metadata.saved_state.prepare", "params": public_saved_state_prepare_call_payload(prepare_payload)}, + "diagnostics": {"message": "Prepare the named parent object in saved-state before building the structural append proposal."}, + } + return result if include_storage else sanitize_object_property_write_result(result) + allow_prepare, allow_prepare_error = strict_bool_argument(payload, "allow_sql_saved_state_prepare", method=method, default=False) + if allow_prepare_error: + return allow_prepare_error + if not allow_prepare: + return invalid_argument(method, "allow_sql_saved_state_prepare", "Automatic saved-state preparation is opt-in; pass allow_sql_saved_state_prepare=true.") + prepare_result = metadata_saved_state_prepare( + {**prepare_payload, "mode": "apply_and_verify", "allow_sql_saved_state_prepare": True} + ) + prepare_status = str(prepare_result.get("status") or "error") + if prepare_status not in {"blocked_target_collision", "verified", "applied"}: + result = { + "schema": "onec_metadata_object_member_add.v1", + "method": method, + "status": prepare_status, + "error": prepare_result.get("error") or "saved_state_not_ready", + "prepare_result": prepare_result, + } + return result if include_storage else sanitize_object_property_write_result(result) + + object_card = prepare_result.get("object") if isinstance(prepare_result.get("object"), dict) else {} + object_guid = str(object_card.get("guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower() + if not is_guid_text(object_guid): + return {"schema": "onec_metadata_object_member_add.v1", "method": method, "status": "not_found", "error": "resolved_object_guid_missing"} + new_guid = deterministic_member_guid(object_guid, "Attribute", new_name, scope=container_ref) + file_names = [str(item) for item in (prepare_result.get("file_names") or []) if isinstance(item, str) and Path(item).name == item] + candidates: list[dict[str, Any]] = [] + diagnostics: list[dict[str, Any]] = [] + for file_name in file_names: + data, _config, read_error = read_storage_file_bytes(base_id, target_table, file_name, timeout_seconds=timeout_seconds) + if read_error: + diagnostics.append({"file_name": file_name, "error": read_error}) + continue + tree = parse_config_tree_from_bytes(data or b"") + template = config_tree_member_identity_resolve( + tree, + parent_guid=object_guid, + parent_kind=str(object_card.get("kind") or payload.get("kind") or ""), + parent_name=str(object_card.get("name") or payload.get("name") or ""), + member_path=list(payload.get("_member_path") or []), + member_kind="Attribute", + member_name=str(payload.get("member_name") or ""), + ) + if template.get("status") != "ok": + diagnostics.append({"file_name": file_name, "template": template}) + continue + template_member = template.get("member") if isinstance(template.get("member"), dict) else {} + identities = config_tree_identity_records(tree) + record = config_tree_declared_record_for_guid(tree, str(template_member.get("guid") or "")) + if record.get("status") != "ok": + diagnostics.append({"file_name": file_name, "record": record}) + continue + duplicate: list[dict[str, Any]] = [] + for guid, identity in identities.items(): + if str(identity.get("name") or "").casefold() != new_name.casefold(): + continue + duplicate_record = config_tree_declared_record_for_guid(tree, guid) + if duplicate_record.get("status") == "ok" and duplicate_record.get("parent_path") == record.get("parent_path"): + duplicate.append({"guid": guid, "name": identity.get("name")}) + if new_guid in identities and not duplicate: + duplicate.append({"guid": new_guid, "name": identities[new_guid].get("name"), "reason": "deterministic_guid_collision"}) + if duplicate: + return { + "schema": "onec_metadata_object_member_add.v1", + "method": method, + "status": "conflict", + "error": "member_name_already_exists", + "base_id": base_id, + "container": {"ref": container_ref}, + "requested_member": {"kind": "Attribute", "name": new_name, "ref": requested_ref}, + "matches": duplicate if include_storage else sanitize_public_result(duplicate), + } + synonym_target = config_tree_identity_property_target(tree, str(template_member.get("guid") or ""), "synonym", language="ru") + comment_target = config_tree_identity_property_target(tree, str(template_member.get("guid") or ""), "comment") + if synonym_target.get("status") != "ok" or comment_target.get("status") != "ok": + diagnostics.append({"file_name": file_name, "synonym": synonym_target, "comment": comment_target}) + continue + candidates.append( + { + "file_name": file_name, + "tree": tree, + "template": template_member, + "record": record, + "synonym": synonym_target, + "comment": comment_target, + } + ) + if len(candidates) != 1: + result = { + "schema": "onec_metadata_object_member_add.v1", + "method": method, + "status": "ambiguous" if len(candidates) > 1 else "not_found", + "error": "attribute_template_target_ambiguous" if len(candidates) > 1 else "attribute_template_target_not_found", + "base_id": base_id, + "counts": {"matches": len(candidates), "candidate_files": len(file_names)}, + "diagnostics": diagnostics, + } + return result if include_storage else sanitize_object_property_write_result(result) + + resolved = candidates[0] + template_member = resolved["template"] + replacements = { + str(template_member.get("guid") or ""): new_guid, + str(template_member.get("name") or ""): new_name, + str(resolved["synonym"].get("current") or ""): new_synonym, + } + if "new_member_comment" in payload: + replacements[str(resolved["comment"].get("current") or "")] = str(payload.get("new_member_comment") or "") + cloned_node = clone_form_structural_node(resolved["record"]["node"], replacements) + proposal = changes_propose( + { + "base_id": base_id, + "source": { + "base_id": base_id, + "table": target_table, + "file_name": resolved["file_name"], + **({"expected_sha1": payload.get("expected_sha1")} if payload.get("expected_sha1") else {}), + }, + "edits": [{"append_child": {"parent_path": resolved["record"]["parent_path"], "node": cloned_node}}], + "include_payload": mode != "plan", + "preserve_format": True, + "timeout_seconds": timeout_seconds, + "summary": payload.get("summary") or f"Add Attribute {new_name} from template {template_member.get('name')}", + } + ) + result: dict[str, Any] = { + "schema": "onec_metadata_object_member_add.v1", + "method": method, + "status": "planned", + "execution_mode": mode, + "base_id": base_id, + "layer": layer, + "object": { + **{key: object_card.get(key) for key in ("kind", "name", "guid") if object_card.get(key) is not None}, + **({"ref": object_selector_ref(object_card.get("kind"), object_card.get("name"))} if object_selector_ref(object_card.get("kind"), object_card.get("name")) else {}), + }, + "container": {"ref": container_ref, "scope": container_scope}, + "template": {key: template_member.get(key) for key in ("kind", "name", "ref") if template_member.get(key) is not None}, + "requested_member": {"kind": "Attribute", "name": new_name, "synonym": new_synonym, "guid": new_guid, "ref": requested_ref}, + "proposal": proposal, + "write_mode": {"target": "saved_state", "active_configuration_write": False, "sql_write_performed": False}, + } + if proposal.get("status") not in {"accepted_for_review", "ok"} or (proposal.get("validation") or {}).get("status") != "ok": + result["status"] = proposal.get("status") if proposal.get("status") not in {"accepted_for_review", "ok"} else "proposal_validation_failed" + return result if include_storage else sanitize_object_property_write_result(result) + if mode == "plan": + result["proposal"] = sanitize_proposal_for_response(proposal) + return result if include_storage else sanitize_object_property_write_result(result) + + allow_apply, allow_apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) + if allow_apply_error: + return allow_apply_error + if not allow_apply: + return invalid_argument(method, "allow_sql_saved_state_apply", "Apply mode is opt-in; pass allow_sql_saved_state_apply=true.") + apply_result = storage_saved_state_apply_proposal( + { + **repository_write_context(payload), + "base_id": base_id, + "allow_sql_saved_state_apply": True, + "proposal": proposal, + "timeout_seconds": timeout_seconds, + } + ) + result["proposal"] = sanitize_proposal_for_response(proposal) + result["apply_result"] = apply_result + result["applied"] = bool(apply_result.get("applied")) + result["status"] = str(apply_result.get("status") or "error") + result["write_mode"]["sql_write_performed"] = result["applied"] + if not result["applied"] or mode == "apply": + return result if include_storage else sanitize_object_property_write_result(result) + + readback_data, _readback_config, readback_error = read_storage_file_bytes( + base_id, target_table, str(resolved["file_name"]), timeout_seconds=timeout_seconds + ) + readback_tree = parse_config_tree_from_bytes(readback_data or b"") if not readback_error else None + identity = config_tree_identity_records(readback_tree).get(new_guid) if readback_tree is not None else None + readback_record = config_tree_declared_record_for_guid(readback_tree, new_guid) if readback_tree is not None else {"status": "error"} + verified = bool( + identity + and identity.get("name") == new_name + and (identity.get("synonyms") or {}).get("ru") == new_synonym + and readback_record.get("status") == "ok" + and readback_record.get("parent_path") == resolved["record"].get("parent_path") + ) + result["semantic_verification"] = { + "status": "ok" if verified else "mismatch", + "member": {"kind": "Attribute", "name": (identity or {}).get("name"), "synonym": ((identity or {}).get("synonyms") or {}).get("ru")}, + "container_match": readback_record.get("parent_path") == resolved["record"].get("parent_path"), + } + if mode == "apply_and_verify": + result["status"] = "verified" if verified else "verification_failed" + return result if include_storage else sanitize_object_property_write_result(result) + + allow_rollback, allow_rollback_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) + if allow_rollback_error: + return allow_rollback_error + if not allow_rollback: + return invalid_argument(method, "allow_sql_saved_state_rollback", "apply_and_rollback mode is opt-in; pass allow_sql_saved_state_rollback=true.") + backup_id = ((apply_result.get("backup") or {}).get("backup_id") if isinstance(apply_result.get("backup"), dict) else None) + if not backup_id: + result["status"] = "rollback_unavailable" + return result if include_storage else sanitize_object_property_write_result(result) + rollback_result = storage_saved_state_rollback( + { + **repository_write_context(payload), + "base_id": base_id, + "allow_sql_saved_state_rollback": True, + "backup_id": backup_id, + "timeout_seconds": timeout_seconds, + } + ) + result["rollback_result"] = rollback_result + result["rolled_back"] = bool(rollback_result.get("applied")) + result["status"] = "verified_and_rolled_back" if verified and result["rolled_back"] else "applied_rollback_failed" + return result if include_storage else sanitize_object_property_write_result(result) + + def metadata_form_element_write_apply(payload: dict[str, Any]) -> dict[str, Any]: method = FORM_ELEMENT_WRITE_APPLY_METHOD + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().lower() if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) @@ -37877,7 +42871,22 @@ def metadata_form_element_write_apply(payload: dict[str, Any]) -> dict[str, Any] "kind": "form", "table": source.get("table") or payload.get("table"), "file_name": source.get("file_name") or payload.get("file_name"), - "form_guid": payload.get("form_guid") or payload.get("guid"), + "form_guid": payload.get("form_guid") or ( + payload.get("guid") + if ( + canonical_kind(str(payload.get("kind") or payload.get("object_type") or "")) == "CommonForm" + or ( + not payload.get("object_guid") + and not payload.get("kind") + and not payload.get("object_type") + and not payload.get("name") + and not payload.get("object_name") + and not payload.get("form") + and not payload.get("form_name") + ) + ) + else None + ), }, ) result["write_plan"] = write_plan @@ -37980,6 +42989,9 @@ def form_move_target_selector(payload: dict[str, Any], prefix: str) -> dict[str, def metadata_form_target_move(payload: dict[str, Any]) -> dict[str, Any]: method = FORM_TARGET_MOVE_METHOD + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error @@ -38005,9 +43017,66 @@ def metadata_form_target_move(payload: dict[str, Any]) -> dict[str, Any]: if table not in FORM_ELEMENT_SAVED_STATE_TABLES: return invalid_argument(method, "table", "Saved-state target move only targets saved-state tables.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES)) + from_selector = form_move_target_selector(payload, "from") + to_selector = form_move_target_selector(payload, "to") or form_move_target_selector(payload, "with") + if not to_selector and payload.get("after_element"): + to_selector = {"element": str(payload.get("after_element"))} + if not from_selector or not to_selector: + return invalid_argument(method, "from/to", "Pass from_element/from_path and to_element/to_path, with_element, or after_element.") + + working_payload = dict(payload) + owner_kind = canonical_kind(str(first_non_empty_arg(working_payload, "kind", "object_type") or "")) + owner_name = str(first_non_empty_arg(working_payload, "name", "object_name") or "").strip() + explicit_form_name = str(first_non_empty_arg(working_payload, "form", "form_name", "name_filter") or "").strip() + is_common_form_request = owner_kind == "CommonForm" or ( + owner_kind in {None, "", "Form"} + and not explicit_form_name + and bool(owner_name) + ) + legacy_guid_is_form = bool( + working_payload.get("guid") + and not working_payload.get("object_guid") + and not owner_kind + and not owner_name + and not explicit_form_name + ) + concrete_form_guid = str( + first_non_empty_arg( + working_payload, + "form_guid", + *(("guid", "object_guid") if is_common_form_request else ("guid",) if legacy_guid_is_form else ()), + ) + or "" + ).strip() + if not (working_payload.get("file_name") or concrete_form_guid): + resolved = metadata_form_write_target_resolve( + { + **working_payload, + **from_selector, + "base_id": base_id, + "table": table, + "timeout_seconds": int(timeout_seconds or 30), + } + ) + if resolved.get("status") != "ok": + result = dict(resolved) + result["method"] = method + return result + source = resolved.get("source") if isinstance(resolved.get("source"), dict) else {} + working_payload["file_name"] = source.get("file_name") + if source.get("table") in FORM_ELEMENT_SAVED_STATE_TABLES: + table = str(source.get("table")) + + decode_payload = without_form_decode_selector_keys(working_payload) + if working_payload.get("file_name"): + for owner_selector_key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid"): + decode_payload.pop(owner_selector_key, None) + if concrete_form_guid: + decode_payload["form_guid"] = concrete_form_guid + decode_payload.pop("guid", None) decoded = metadata_form_decode( { - **without_form_decode_selector_keys(payload), + **decode_payload, "base_id": base_id, "table": table, "include_storage": True, @@ -38021,18 +43090,12 @@ def metadata_form_target_move(payload: dict[str, Any]) -> dict[str, Any]: result["method"] = method return result source = decoded.get("source") if isinstance(decoded.get("source"), dict) else {} - file_name = str(source.get("file_name") or (decoded.get("form") or {}).get("file_name") or payload.get("file_name") or "") + file_name = str(source.get("file_name") or (decoded.get("form") or {}).get("file_name") or working_payload.get("file_name") or "") if not file_name: return invalid_argument(method, "file_name", "Could not resolve saved-state form source file_name.") profile = decoded.get("profile") if isinstance(decoded.get("profile"), dict) else {} targets = [target for target in form_profile_write_targets(profile) if str(target.get("_profile_section") or "") == "items"] - from_selector = form_move_target_selector(payload, "from") - to_selector = form_move_target_selector(payload, "to") or form_move_target_selector(payload, "with") - if not to_selector and payload.get("after_element"): - to_selector = {"element": str(payload.get("after_element"))} - if not from_selector or not to_selector: - return invalid_argument(method, "from/to", "Pass from_element/from_path and to_element/to_path, with_element, or after_element.") from_items = filter_form_profile_write_targets(targets, from_selector) to_items = filter_form_profile_write_targets(targets, to_selector) if len(from_items) != 1 or len(to_items) != 1: @@ -38437,7 +43500,16 @@ def metadata_form_command_button_verify(payload: dict[str, Any]) -> dict[str, An include_storage, include_storage_error = strict_include_storage(payload, method) if include_storage_error: return include_storage_error - form_name = str(first_non_empty_arg(payload, "form", "form_name", "name_filter", "name", "object_name") or "").strip() + owner_kind = canonical_kind(str(payload.get("kind") or payload.get("object_type") or "")) or str(payload.get("kind") or payload.get("object_type") or "") + owner_name = str(payload.get("name") or payload.get("object_name") or "").strip() + explicit_form_name = str(first_non_empty_arg(payload, "form", "form_name", "name_filter") or "").strip() + form_name = explicit_form_name or (owner_name if owner_kind == "CommonForm" else "") + if owner_name and owner_kind != "CommonForm" and not form_name: + return invalid_argument( + method, + "form_name", + "Pass form or form_name when verifying a command in a nested owner form.", + ) command_name = str(first_non_empty_arg(payload, "command_name", "command") or "").strip() if not command_name: return invalid_argument(method, "command_name", "Pass command_name, for example РасчетС.") @@ -38474,6 +43546,12 @@ def metadata_form_command_button_verify(payload: dict[str, Any]) -> dict[str, An item_name = str(item.get("name") or form_info.get("name") or "").strip() if form_name and item_name and normalize(item_name) != normalize(form_name): continue + item_owner = item.get("owner") if isinstance(item.get("owner"), dict) else {} + if owner_name and owner_kind != "CommonForm": + if normalize(item_owner.get("name")) != normalize(owner_name): + continue + if owner_kind and canonical_kind(str(item_owner.get("kind") or "")) != owner_kind: + continue source = item.get("source") if isinstance(item.get("source"), dict) else {} file_info = item.get("file") if isinstance(item.get("file"), dict) else {} candidate_table = str(source.get("table") or item.get("table") or "") @@ -38494,7 +43572,12 @@ def metadata_form_command_button_verify(payload: dict[str, Any]) -> dict[str, An "status": "not_found", "base_id": base_id, "error": "saved_state_form_not_found", - "query": {"form": form_name or None, "command": command_name, "extension": payload.get("extension")}, + "query": { + "owner": {"kind": owner_kind or None, "name": owner_name or None}, + "form": form_name or None, + "command": command_name, + "extension": payload.get("extension"), + }, **({"saved_state_search": compact_saved_state_form_search_result(saved_state, selected_form=selected_saved_form, include_storage=bool(include_storage))} if isinstance(saved_state, dict) else {}), } result = form_command_button_semantic_verify( @@ -38510,7 +43593,14 @@ def metadata_form_command_button_verify(payload: dict[str, Any]) -> dict[str, An if not include_storage: result["source"] = {"table": table} result["method"] = method - result["query"] = {"form": form_name or None, "command": command_name, "button": button_name, "handler": handler_name, "extension": payload.get("extension")} + result["query"] = { + "owner": {"kind": owner_kind or None, "name": owner_name or None}, + "form": form_name or None, + "command": command_name, + "button": button_name, + "handler": handler_name, + "extension": payload.get("extension"), + } if isinstance(saved_state, dict): result["saved_state_search"] = compact_saved_state_form_search_result(saved_state, selected_form=selected_saved_form, include_storage=bool(include_storage)) return result @@ -38839,6 +43929,7 @@ def metadata_form_command_button_write(payload: dict[str, Any]) -> dict[str, Any "command_title", "command_action", "handler", + "handler_name", "handler_routine_operation", "handler_routine_text", "routine_operation", @@ -38877,32 +43968,48 @@ def metadata_form_command_button_write(payload: dict[str, Any]) -> dict[str, Any if repository_error: return repository_error - form_name = str(first_non_empty_arg(payload, "form", "form_name", "name_filter", "name", "object_name") or "").strip() + requested_kind = canonical_kind(str(first_non_empty_arg(payload, "kind", "object_type") or "")) + owner_name = str(first_non_empty_arg(payload, "name", "object_name") or "").strip() + explicit_form_name = str(first_non_empty_arg(payload, "form", "form_name", "name_filter") or "").strip() + is_common_form_request = requested_kind == "CommonForm" or ( + requested_kind in {None, "", "Form"} + and not explicit_form_name + and bool(owner_name) + ) + form_name = explicit_form_name or (owner_name if is_common_form_request else "") + if owner_name and not is_common_form_request and not form_name: + return invalid_argument( + method, + "form_name", + "Pass form or form_name when writing a command to a nested owner form.", + ) command_name = str(first_non_empty_arg(payload, "command_name", "command") or "").strip() if not command_name: return invalid_argument(method, "command_name", "Pass command_name, for example РасчетС.") command_title = str(first_non_empty_arg(payload, "command_title", "title") or command_name).strip() - command_action = str(first_non_empty_arg(payload, "command_action", "handler") or command_name).strip() + command_action = str(first_non_empty_arg(payload, "command_action", "handler_name", "handler") or command_name).strip() button_name = str(first_non_empty_arg(payload, "button_name", "button") or command_name).strip() button_title = str(first_non_empty_arg(payload, "button_title") or command_title).strip() button_parent = str(first_non_empty_arg(payload, "button_parent_name", "button_parent", "parent") or "ФормаКоманднаяПанель").strip() - requested_kind = canonical_kind(str(first_non_empty_arg(payload, "kind", "object_type") or "")) - is_common_form_request = requested_kind == "CommonForm" or ( - requested_kind in {None, "", "Form"} - and not payload.get("form") - and not payload.get("form_name") - and bool(form_name) - ) target = { "kind": "CommonForm" if is_common_form_request else (requested_kind or None), "form": form_name or None, - "object": None if is_common_form_request else str(first_non_empty_arg(payload, "name", "object_name") or "").strip() or None, + "object": None if is_common_form_request else owner_name or None, + "object_guid": None if is_common_form_request else str(first_non_empty_arg(payload, "guid", "object_guid") or "").strip().lower() or None, "extension": str(payload.get("extension") or "").strip() or None, "extension_guid": str(payload.get("extension_guid") or "").strip() or None, "table": str(payload.get("table") or "").strip() or None, "file_name": str(payload.get("file_name") or "").strip() or None, - "form_guid": str(first_non_empty_arg(payload, "form_guid", "guid", "object_guid") or "").strip().lower() or None, + "form_guid": str( + first_non_empty_arg( + payload, + "form_guid", + *(("guid", "object_guid") if is_common_form_request else ()), + ) + or "" + ).strip().lower() + or None, } saved_state_query = { @@ -38925,6 +44032,12 @@ def metadata_form_command_button_write(payload: dict[str, Any]) -> dict[str, Any for item in saved_forms: if not isinstance(item, dict): continue + item_owner = item.get("owner") if isinstance(item.get("owner"), dict) else {} + if owner_name and not is_common_form_request: + if normalize(item_owner.get("name")) != normalize(owner_name): + continue + if requested_kind and canonical_kind(str(item_owner.get("kind") or "")) != requested_kind: + continue source = item.get("source") if isinstance(item.get("source"), dict) else {} table = str(source.get("table") or item.get("table") or "") file_name = str(source.get("file_name") or item.get("file_name") or "") @@ -39303,12 +44416,19 @@ def metadata_form_command_button_write(payload: dict[str, Any]) -> dict[str, Any }, } if prepare_plan is not None: - result["next_resolution"] = {"method": "metadata.saved_state.prepare", "payload": prepare_payload} - result["prepare_plan"] = prepare_plan + result["next_resolution"] = { + "method": "metadata.saved_state.prepare", + "params": public_saved_state_prepare_call_payload(prepare_payload), + } + result["prepare_plan"] = public_saved_state_prepare_embedded_result(prepare_plan, prepare_payload) result["next_resolution"] = [ { "method": "metadata.saved_state.forms.search", - "payload": saved_state_query, + "params": { + key: value + for key, value in saved_state_query.items() + if key not in {"table", "tables", "file_name", "include_storage"} and value is not None + }, "purpose": "Re-check whether Designer has a saved-state form row that can be safely patched.", }, { @@ -39319,7 +44439,7 @@ def metadata_form_command_button_write(payload: dict[str, Any]) -> dict[str, Any }, { "method": "metadata.saved_state.prepare", - "payload": prepare_payload, + "params": public_saved_state_prepare_call_payload(prepare_payload), "purpose": "Copy the target form from Config/ConfigCAS into ConfigSave/ConfigCASSave, then call this method again.", }, ] @@ -39698,13 +44818,18 @@ def metadata_write_resolve_module_target(payload: dict[str, Any], target: dict[s target_table = SAVED_STATE_TARGET_BY_SOURCE[module_table] prepared_ref = f"{target_table}:{module_file_name}" + (f"#stream:{module_stream_index}" if module_stream_index is not None else "") prepare_payload = { + **repository_write_context(write_payload), "base_id": payload.get("base_id"), "source_table": module_table, "target_table": target_table, "module_ref": module_ref_value, + "include_storage": True, "mode": "apply_and_verify" if auto_prepare or payload.get("allow_sql_saved_state_prepare") else "plan", "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), } + for key in ("extension", "ref", "kind", "name", "object_type", "object_name", "query"): + if write_payload.get(key) is not None: + prepare_payload[key] = write_payload.get(key) if auto_prepare or payload.get("allow_sql_saved_state_prepare"): prepare_payload["allow_sql_saved_state_prepare"] = True prepare_result = metadata_saved_state_prepare(prepare_payload) @@ -39720,9 +44845,13 @@ def metadata_write_resolve_module_target(payload: dict[str, Any], target: dict[s "base_id": payload.get("base_id"), "error": "saved_state_prepare_required", "diagnostics": {"message": "Module write target points to active storage. Prepare the saved-state layer first, then write to the saved-state module_ref."}, - "next_resolution": {"method": "metadata.saved_state.prepare", "payload": prepare_payload}, - "prepare_plan": prepare_result, - "prepared_module_ref": prepared_ref, + "next_resolution": { + "method": "metadata.saved_state.prepare", + "params": public_saved_state_prepare_call_payload(prepare_payload), + }, + "prepare_plan": public_saved_state_prepare_embedded_result(prepare_result, prepare_payload), + "prepared_target": {"layer": SAVED_STATE_LAYER_BY_TABLE[target_table]}, + **({"prepared_module_ref": prepared_ref} if payload.get("include_storage") is True else {}), } if module_table in FORM_ELEMENT_SAVED_STATE_TABLES and module_file_name and module_stream_index is None and write_payload.get("stream_index") is None: return metadata_write_embedded_form_module_payload( @@ -39757,6 +44886,7 @@ def metadata_write_resolve_module_target(payload: dict[str, Any], target: dict[s "scan_limit": int(write_payload.get("scan_limit") or 1000), "preview_chars": int(write_payload.get("preview_chars") or 200), "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), + "include_storage": True, } search_payload = {key: value for key, value in search_payload.items() if value is not None} search = metadata_saved_state_modules_search(search_payload) @@ -39775,6 +44905,7 @@ def metadata_write_resolve_module_target(payload: dict[str, Any], target: dict[s prepare_payload = { "base_id": payload.get("base_id"), "target_table": write_payload.get("target_table") or ("ConfigCASSave" if (write_payload.get("extension") or write_payload.get("preferred_extension")) else "ConfigSave"), + "include_storage": True, "mode": "apply_and_verify" if auto_prepare or payload.get("allow_sql_saved_state_prepare") else "plan", "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), } @@ -39810,7 +44941,17 @@ def metadata_write_resolve_module_target(payload: dict[str, Any], target: dict[s "error": "module_target_not_resolved", "diagnostics": {"message": "Module write requires exactly one saved-state module stream. Pass module_ref, prepare saved-state, or narrow owner_guid/file_name/query/stream_index."}, "search": search, - **({"next_resolution": {"method": "metadata.saved_state.prepare", "payload": prepare_payload}, "prepare_plan": prepare_plan} if prepare_plan is not None else {}), + **( + { + "next_resolution": { + "method": "metadata.saved_state.prepare", + "params": public_saved_state_prepare_call_payload(prepare_payload), + }, + "prepare_plan": public_saved_state_prepare_embedded_result(prepare_plan, prepare_payload), + } + if prepare_plan is not None + else {} + ), "counts": {"stream_matches": len(matches)}, } module, stream = matches[0] @@ -39841,6 +44982,7 @@ def metadata_write_prepare_payload( prepare_payload = { "base_id": payload.get("base_id"), "target_table": target_table, + "include_storage": True, "mode": "apply_and_verify" if auto_prepare or payload.get("allow_sql_saved_state_prepare") else "plan", "timeout_seconds": int(write_payload.get("timeout_seconds") or 60), } @@ -39898,9 +45040,16 @@ def metadata_write_resolve_form_target(payload: dict[str, Any], target: dict[str "base_id": payload.get("base_id"), "error": "saved_state_prepare_required", "diagnostics": {"message": "Form write target points to active storage. Prepare the saved-state layer first, then write to the saved-state form payload."}, - "next_resolution": {"method": "metadata.saved_state.prepare", "payload": prepare_payload}, - "prepare_plan": prepare_result, - "prepared_target": {"table": target_table, **({"file_name": write_payload.get("file_name")} if write_payload.get("file_name") else {})}, + "next_resolution": { + "method": "metadata.saved_state.prepare", + "params": public_saved_state_prepare_call_payload(prepare_payload), + }, + "prepare_plan": public_saved_state_prepare_embedded_result(prepare_result, prepare_payload), + "prepared_target": ( + {"table": target_table, **({"file_name": write_payload.get("file_name")} if write_payload.get("file_name") else {})} + if payload.get("include_storage") is True + else {"layer": SAVED_STATE_LAYER_BY_TABLE[target_table]} + ), } @@ -39920,13 +45069,20 @@ def metadata_write_form_retry_after_prepare(payload: dict[str, Any], write_paylo retry_result = metadata_form_element_write_apply(write_payload) return retry_result, {"method": "metadata.saved_state.prepare", "result": prepare_result, "retry_status": retry_result.get("status")} enriched = dict(result) - enriched.setdefault("next_resolution", {"method": "metadata.saved_state.prepare", "payload": prepare_payload}) - enriched.setdefault("prepare_plan", prepare_result) + enriched.setdefault( + "next_resolution", + { + "method": "metadata.saved_state.prepare", + "params": public_saved_state_prepare_call_payload(prepare_payload), + }, + ) + enriched.setdefault("prepare_plan", public_saved_state_prepare_embedded_result(prepare_result, prepare_payload)) return enriched, {"method": "metadata.saved_state.prepare", "result": prepare_result} def metadata_write_plan_path_parts(path: str) -> dict[str, Any]: - parts = [part.strip() for part in str(path or "").split(".") if part.strip()] + parsed = parse_1c_object_path(path) + parts = parsed["parts"] result: dict[str, Any] = { "input": path, "parts": parts, @@ -39936,12 +45092,13 @@ def metadata_write_plan_path_parts(path: str) -> dict[str, Any]: if len(parts) < 2: result["reason"] = "local_or_short_name" return result - kind = canonical_kind(parts[0]) - if not kind: + kind = parsed.get("kind") + name = str(parsed.get("name") or "") + if not parsed.get("recognized_root") or not kind or not name: result["reason"] = "unknown_object_kind" return result - member_path = parts[2:] - path_kind = "metadata_object" if len(parts) == 2 else "metadata_member" + member_path = list(parsed.get("member_path") or []) + path_kind = "metadata_object" if not member_path else "metadata_member" section = member_path[0] if member_path else None section_class = normalize(section or "") extra: dict[str, Any] = {} @@ -39962,14 +45119,14 @@ def metadata_write_plan_path_parts(path: str) -> dict[str, Any]: elif kind == "CommonModule" and member_path: path_kind = "module_routine" extra["section"] = "module" - extra["module_name"] = parts[1] + extra["module_name"] = name extra["routine_name"] = member_path[-1] elif kind == "CommonForm" and member_path: common_form_section = normalize(member_path[0]) if common_form_section in {normalize("Команда"), normalize("Команды"), "command", "commands"}: path_kind = "form_command" extra["section"] = "form_command" - extra["form_name"] = parts[1] + extra["form_name"] = name extra["form_member_path"] = member_path[1:] if len(member_path) > 1: extra["command_name"] = member_path[-1] @@ -39977,36 +45134,37 @@ def metadata_write_plan_path_parts(path: str) -> dict[str, Any]: path_kind = "form_element" extra["section"] = "form_element" extra["form_member_kind"] = "button" if common_form_section in {normalize("Кнопка"), normalize("Кнопки"), "button", "buttons"} else "element" - extra["form_name"] = parts[1] + extra["form_name"] = name extra["form_member_path"] = member_path[1:] if len(member_path) > 1: extra["element_name"] = member_path[-1] elif common_form_section in {normalize("Атрибут"), normalize("Атрибуты"), normalize("Реквизит"), normalize("Реквизиты"), "attribute", "attributes"}: path_kind = "form_attribute" extra["section"] = "form_attribute" - extra["form_name"] = parts[1] + extra["form_name"] = name extra["form_member_path"] = member_path[1:] if len(member_path) > 1: extra["attribute_name"] = member_path[-1] else: path_kind = "module_routine" extra["section"] = "form_module" - extra["form_name"] = parts[1] + extra["form_name"] = name extra["routine_name"] = member_path[-1] result.update( { "is_full_path": True, "kind": kind, "kind_ru": RU_KIND.get(kind, kind), - "name": parts[1], - "canonical_path": ".".join([RU_KIND.get(kind, kind), *parts[1:]]), + "name": name, + "canonical_path": ".".join([RU_KIND.get(kind, kind), name, *member_path]), + "code_path": ".".join([ONEC_CODE_ROOT_BY_KIND.get(kind, RU_KIND.get(kind, kind)), name, *member_path]), "member_path": member_path, "path_kind": path_kind, **extra, } ) - if len(parts) >= 4: - result["context_path"] = ".".join(parts[2:]) + if len(member_path) >= 2: + result["context_path"] = ".".join(member_path) return result @@ -40272,6 +45430,35 @@ def metadata_write_plan_apply_payload_hint( concrete_reference: str, ) -> dict[str, Any] | None: concrete_reference_info = metadata_write_concrete_reference_info(payload, target) + selector_kind = str( + path_resolution.get("kind") + or target.get("kind") + or target.get("object_type") + or payload.get("kind") + or payload.get("object_type") + or "" + ).strip() + selector_name = str( + path_resolution.get("name") + or target.get("name") + or target.get("object_name") + or payload.get("name") + or payload.get("object_name") + or "" + ).strip() + selector_ref = str(target.get("ref") or payload.get("ref") or object_selector_ref(selector_kind, selector_name) or "").strip() + public_resolution_params: dict[str, Any] = { + "base_id": payload.get("base_id"), + **({"ref": selector_ref} if selector_ref and not is_guid_text(selector_ref) else {}), + **({"kind": canonical_kind(selector_kind)} if selector_kind else {}), + **({"name": selector_name} if selector_name else {}), + **( + {"extension": target.get("extension") or payload.get("extension")} + if isinstance(target.get("extension") or payload.get("extension"), str) + and (target.get("extension") or payload.get("extension")) + else {} + ), + } if target_kind == "module": hint: dict[str, Any] = { "method": MODULE_WRITE_APPLY_METHOD, @@ -40295,6 +45482,10 @@ def metadata_write_plan_apply_payload_hint( hint["next_resolution"] = { "method": SAVED_STATE_MODULES_SEARCH_METHOD, "reason": "metadata.module.write_apply requires module_ref or saved-state file_name/stream_index.", + "params": { + **public_resolution_params, + **({"query": path_resolution.get("routine_name")} if path_resolution.get("routine_name") else {}), + }, } if path_resolution.get("routine_name") and "routine_name" not in hint["payload"]: hint["payload"]["routine_name"] = path_resolution.get("routine_name") @@ -40319,10 +45510,14 @@ def metadata_write_plan_apply_payload_hint( hint["next_resolution"] = { "method": SAVED_STATE_MODULES_SEARCH_METHOD, "reason": "metadata.module.write_apply requires a concrete module stream (#stream:). Form embedded container modules must not be written as a plain stream.", - "payload": { - "base_id": payload.get("base_id"), - **({"file_name": hint["payload"].get("file_name") or parse_module_id(str(hint["payload"].get("module_ref") or hint["payload"].get("module_id") or ""))[1]} if (hint["payload"].get("file_name") or hint["payload"].get("module_ref") or hint["payload"].get("module_id")) else {}), + "params": { + **public_resolution_params, **({"query": hint["payload"].get("routine_name")} if hint["payload"].get("routine_name") else {}), + **( + {"selector_required": "Pass a public 1C ref or kind/name selector."} + if not any(public_resolution_params.get(key) for key in ("ref", "name")) + else {} + ), }, } control_fragment = metadata_write_plan_first_value(payload, target, intent, "control_fragment", "controlled_fragment") @@ -40337,6 +45532,8 @@ def metadata_write_plan_apply_payload_hint( hint["payload"]["routine_operation"] = "append" if operation_class == "append_routine" else "upsert" return hint if target_kind == "form": + form_member_path = path_resolution.get("form_member_path") if isinstance(path_resolution.get("form_member_path"), list) else [] + resolution_element = path_resolution.get("element_name") or (form_member_path[-1] if form_member_path else None) hint = { "method": FORM_ELEMENT_WRITE_APPLY_METHOD, "payload": { @@ -40359,10 +45556,16 @@ def metadata_write_plan_apply_payload_hint( hint["next_resolution"] = { "method": FORM_WRITE_TARGET_RESOLVE_METHOD, "reason": "metadata.form.element.write_apply requires saved-state table/file/form target resolution.", + "params": { + **public_resolution_params, + **({"form": path_resolution.get("form_name")} if path_resolution.get("form_name") else {}), + **({"command": path_resolution.get("command_name")} if path_resolution.get("command_name") else {}), + **({"element": resolution_element} if resolution_element else {}), + **({"attribute": path_resolution.get("attribute_name")} if path_resolution.get("attribute_name") else {}), + }, } if path_resolution.get("form_name"): hint["payload"]["form"] = path_resolution.get("form_name") - form_member_path = path_resolution.get("form_member_path") if isinstance(path_resolution.get("form_member_path"), list) else [] if path_resolution.get("command_name"): hint["payload"]["command"] = path_resolution.get("command_name") elif path_resolution.get("element_name"): @@ -41006,22 +46209,247 @@ def metadata_write_preflight_status(plan: dict[str, Any], saved_state: dict[str, return "ready" +def live_sql_support_gate(payload: dict[str, Any]) -> dict[str, Any]: + """Evaluate support from live SQL, falling back fail-closed to configured policy.""" + configured = repository_control.support_gate(payload) + layer_id = repository_control.development_layer_id(payload) + target = payload.get("target") if isinstance(payload.get("target"), dict) else {} + object_guid = str( + payload.get("support_object_guid") + or payload.get("object_guid") + or target.get("object_guid") + or target.get("guid") + or "" + ).strip().lower() + normalized = normalize_object_selector_aliases(payload, "metadata.support.decode") + if not (isinstance(normalized, dict) and normalized.get("status") == "invalid_argument"): + if not object_guid: + object_guid = str(normalized.get("guid") or "").strip().lower() + if not object_guid and normalized.get("name"): + if layer_id == "base": + card = get_object( + normalized.get("kind"), + str(normalized.get("name") or ""), + base_id=str(payload.get("base_id") or ""), + include_semantic=False, + timeout_seconds=int(payload.get("timeout_seconds") or 60), + ) + object_guid = str((card.get("object") or {}).get("guid") or "").lower() + else: + extension_guid = layer_id.split(":", 1)[1] + found = extension_objects_find( + { + "base_id": payload.get("base_id"), + "extension_guid": extension_guid, + "query": normalized.get("name"), + "kind": normalized.get("kind"), + "limit": 20, + "scan_limit": 5000, + } + ) + name_folded = str(normalized.get("name") or "").casefold() + exact = next( + ( + item + for item in (found.get("objects") or []) + if str(item.get("name") or "").casefold() == name_folded + ), + None, + ) + object_guid = str((exact or {}).get("guid") or "").lower() + if not is_guid_text(object_guid): + return configured + live = metadata_support_decode( + { + "base_id": payload.get("base_id"), + "layer_id": layer_id, + "object_guid": object_guid, + "timeout_seconds": payload.get("timeout_seconds", 30), + } + ) + object_support = live.get("object_support") if isinstance(live.get("object_support"), dict) else None + if live.get("status") != "ok" or not object_support: + return { + **configured, + "live_sql": { + "status": live.get("status"), + "diagnostics": live.get("diagnostics"), + }, + } + allowed = object_support.get("edit_allowed_by_support") is True + return { + "required": object_support.get("status") != "not_supported", + "allowed": allowed, + "status": "support_live_sql_editable" if allowed else "blocked_by_support_live_sql", + "layer_id": layer_id, + "mode": "live_sql", + "object_guid": object_guid, + "rule": object_support.get("status"), + "evidence": object_support.get("evidence"), + "source_discovery": (live.get("diagnostics") or {}).get("source_discovery"), + } + + +def repository_layers_audit(payload: dict[str, Any]) -> dict[str, Any]: + """Report policy per real configuration layer without consulting local caches.""" + base_id_or_error = require_base_id(payload, "repository.layers.audit") + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + extensions_result = list_extensions({"base_id": base_id, "timeout_seconds": payload.get("timeout_seconds", 30)}) + extensions = extensions_result.get("extensions") if extensions_result.get("status") == "ok" else [] + candidates = [{"layer_id": "base", "kind": "base", "name": None, "guid": None}] + for extension in extensions if isinstance(extensions, list) else []: + if not isinstance(extension, dict) or not is_guid_text(str(extension.get("guid") or "")): + continue + guid = str(extension["guid"]).lower() + candidates.append({"layer_id": f"extension:{guid}", "kind": "extension", "name": extension.get("name"), "guid": guid}) + layers: list[dict[str, Any]] = [] + for candidate in candidates: + layer_id = str(candidate["layer_id"]) + repository = repository_control.status({"base_id": base_id, "layer_id": layer_id}) + support = metadata_support_decode({"base_id": base_id, "layer_id": layer_id, "timeout_seconds": payload.get("timeout_seconds", 30)}) + repository_status = str(repository.get("status") or "unknown") + if repository_status == "repository_not_connected": + next_write_action = "repository_lock_not_required" + elif repository_status in {"repository_connection_unknown", "repository_state_unknown"}: + next_write_action = "configure_repository_connection_state" + else: + next_write_action = "request_and_confirm_repository_capture" + suppliers = support.get("suppliers") if isinstance(support.get("suppliers"), list) else [] + support_overview = { + "status": support.get("status"), + "supplier_count": len(suppliers), + "general_modes": sorted({str(item.get("general_mode")) for item in suppliers if isinstance(item, dict) and item.get("general_mode")}), + "support_payload_present": (support.get("source") or {}).get("support_payload_present"), + "source_discovery": (support.get("diagnostics") or {}).get("source_discovery"), + } + layers.append({ + **candidate, + "repository": repository, + "support": { + **support_overview, + "object_support": support.get("object_support"), + "source": support.get("source"), + }, + "safe_next_write_action": next_write_action, + }) + return { + "schema": "onec_repository_layers_audit.v1", + "method": "repository.layers.audit", + "status": "ok" if extensions_result.get("status") == "ok" else "partial", + "base_id": base_id, + "layers": layers, + "extensions_discovery": {"status": extensions_result.get("status"), "diagnostics": extensions_result.get("diagnostics")}, + } + + def repository_apply_gate(payload: dict[str, Any], method: str, mode: str) -> dict[str, Any] | None: if mode not in {"apply", "apply_and_verify", "apply_and_rollback"}: return None + payload, context_error = normalize_repository_write_context(payload, method) + if context_error: + return { + "schema": "onec_repository_write_gate.v1", "method": method, + "base_id": payload.get("base_id"), **context_error, + } gate = repository_control.write_gate(payload) - if gate.get("allowed") is True: + support = live_sql_support_gate(payload) + if gate.get("allowed") is True and support.get("allowed") is True: return None + blocked_gate = gate if gate.get("allowed") is not True else support return { "schema": "onec_repository_write_gate.v1", "method": method, "status": "blocked", - "error": str(gate.get("status") or "repository_lock_required"), - "base_id": payload.get("base_id"), "repository": gate, - "diagnostics": {"message": "The configured repository requires a verified adapter lock session before saved-state apply."}, + "error": str(blocked_gate.get("status") or "write_authorization_required"), + "base_id": payload.get("base_id"), "repository": gate, "support": support, + "diagnostics": {"message": "Saved-state apply requires both repository and support gates to allow the exact development layer and target."}, } +def metadata_write_is_schedule_target(payload: dict[str, Any]) -> bool: + target = payload.get("target") if isinstance(payload.get("target"), dict) else {} + value = str(target.get("kind") or payload.get("target_kind") or "").strip().casefold() + return value in {"schedule", "scheduled_job", "scheduledjob", "расписание", "регламентноезадание", "регламентное_задание"} + + def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]: method = METADATA_WRITE_PREFLIGHT_METHOD + payload, context_error = normalize_repository_write_context(payload, method) + if context_error: + return { + "schema": "onec_metadata_write_preflight.v1", "method": method, + "base_id": payload.get("base_id"), "allowed": False, **context_error, + } + payload = resolve_write_gate_context(payload) + if metadata_write_is_schedule_target(payload): + schedule_plan = metadata_scheduled_job_schedule_write( + { + **payload, + "allow_saved_state_write": True, + "execution_mode": "plan", + "include_payload": False, + } + ) + object_card = schedule_plan.get("object") if isinstance(schedule_plan.get("object"), dict) else {} + gate_payload = { + **payload, + **({"object_guid": object_card.get("guid")} if object_card.get("guid") else {}), + } + repository_gate = repository_control.write_gate(gate_payload) + support_gate = live_sql_support_gate(gate_payload) + schedule_status = str(schedule_plan.get("status") or "blocked") + status = "ready" if schedule_status in {"planned", "unchanged"} else schedule_status + if repository_gate.get("allowed") is not True: + status = str(repository_gate.get("status") or "blocked") + elif support_gate.get("allowed") is not True: + status = str(support_gate.get("status") or "blocked") + source = schedule_plan.get("source") if isinstance(schedule_plan.get("source"), dict) else {} + return { + "schema": "onec_metadata_write_preflight.v1", + "method": method, + "status": status, + "allowed": status == "ready", + "base_id": payload.get("base_id"), + "target": { + "target_kind": "schedule", + "ref": object_card.get("ref"), + "guid": object_card.get("guid"), + }, + "route": { + "writer": "metadata.write:scheduled_job_schedule", + "write_surface": "saved_state", + "ready_for_apply_method": status == "ready", + "auto_resolves_saved_state": True, + }, + "saved_state": { + "status": schedule_status, + "target": { + **({"table": source.get("table")} if source.get("table") else {}), + **({"file_name": source.get("file_name")} if source.get("file_name") else {}), + }, + "needs_prepare": schedule_status == "needs_prepare", + "freshness": {"source": "live_sql", "status": "live_sql_verified", "verified_against_sql": True}, + }, + "repository": repository_gate, + "support": support_gate, + "guards": { + "required": ["allow_saved_state_write", "expected_sha1", "backup", "readback_verification"], + "requires_saved_state_prepare": schedule_status == "needs_prepare", + "requires_backup": True, + "rollback_available": schedule_status in {"planned", "unchanged"}, + "expected_sha1": ((schedule_plan.get("proposal") or {}).get("original") or {}).get("sha1"), + }, + "plan": { + "method": METADATA_WRITE_METHOD, + "status": schedule_status, + "allowed": schedule_status in {"planned", "unchanged"}, + "problems": [] if schedule_status in {"planned", "unchanged"} else [schedule_plan.get("diagnostics") or {"message": schedule_status}], + }, + "diagnostics": { + "read_only": True, + "message": "metadata.write.preflight resolved the scheduled job by public 1C name and verified its ConfigSave schedule payload without applying SQL writes.", + }, + } plan_payload = dict(payload) plan_payload.setdefault("resolve_origin", payload.get("resolve_origin", False)) plan = metadata_write_plan(plan_payload) @@ -41035,6 +46463,7 @@ def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]: ) saved_target = metadata_write_preflight_saved_target(payload, plan) saved_state: dict[str, Any] | None = None + module_prepare: dict[str, Any] | None = None if saved_target.get("table") and saved_target.get("file_name"): diff_payload = { "base_id": payload.get("base_id"), @@ -41079,12 +46508,57 @@ def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]: "verified_against_sql": bool(command_button_route), }, } + # A raw active module reference has enough information to plan the + # Config -> ConfigSave transition without writing. Present that concrete + # transition instead of the generic origin-resolution failure. + target_for_module = payload.get("target") if isinstance(payload.get("target"), dict) else {} + module_ref_for_prepare = str( + target_for_module.get("module_ref") or target_for_module.get("module_id") + or payload.get("module_ref") or payload.get("module_id") or "" + ).strip() + if ( + module_ref_for_prepare + and isinstance(payload.get("owner_resolution"), dict) + and payload["owner_resolution"].get("status") == "resolved" + ): + module_resolution = metadata_write_resolve_module_target(payload, target_for_module, "plan") + module_plan_result = module_resolution[0] if isinstance(module_resolution, tuple) else module_resolution + if isinstance(module_plan_result, dict) and module_plan_result.get("error") == "saved_state_prepare_required": + module_prepare = module_plan_result + prepared_ref = str(module_plan_result.get("prepared_module_ref") or "").strip() + prepared_table, prepared_file_name, prepared_stream_index = parse_module_id(prepared_ref) + saved_target = { + "table": prepared_table or "ConfigSave", + "file_name": prepared_file_name or None, + "module_ref": prepared_ref or None, + **({"stream_index": prepared_stream_index} if prepared_stream_index is not None else {}), + } + saved_state = { + "status": "needs_prepare", + "target": saved_target, + "needs_prepare": True, + "prepare_payload": module_plan_result.get("next_resolution"), + "prepare_plan": module_plan_result.get("prepare_plan"), + "freshness": {"source": "live_sql", "status": "live_sql_verified", "verified_against_sql": True}, + } status = metadata_write_preflight_status(plan, saved_state) + if module_prepare: + status = "needs_prepare" if command_button_route: status = "ready" - repository_gate = repository_control.write_gate(payload) + # The metadata resolver is the authority for the configuration layer. Do + # not require callers to select base/extension manually once origin is known. + gate_payload = dict(payload) + plan_target = plan.get("target") if isinstance(plan.get("target"), dict) else {} + if plan_target: + caller_target = payload.get("target") if isinstance(payload.get("target"), dict) else {} + gate_payload["target"] = {**caller_target, **plan_target} + repository_gate = repository_control.write_gate(gate_payload) + support_gate = live_sql_support_gate(gate_payload) if repository_gate.get("allowed") is not True: status = str(repository_gate.get("status") or "blocked") + elif support_gate.get("allowed") is not True: + status = str(support_gate.get("status") or "blocked") hint = metadata_write_plan_apply_hint(plan) hint_payload = hint.get("payload") if isinstance(hint, dict) and isinstance(hint.get("payload"), dict) else {} guards = { @@ -41104,14 +46578,17 @@ def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]: "base_id": payload.get("base_id"), "target": plan.get("target"), "route": { - "writer": FORM_COMMAND_BUTTON_WRITE_METHOD if command_button_route else route.get("apply_method"), - "write_surface": route.get("write_surface"), + "writer": FORM_COMMAND_BUTTON_WRITE_METHOD if command_button_route else ("metadata.module.write_apply" if module_prepare else route.get("apply_method")), + "write_surface": "saved_state" if module_prepare else route.get("write_surface"), "ready_for_apply_method": hint.get("ready_for_apply_method") if isinstance(hint, dict) else None, "apply_payload_hint": hint, - "auto_resolves_saved_state": command_button_route, + "auto_resolves_saved_state": bool(command_button_route or module_prepare), + **({"next_resolution": module_prepare.get("next_resolution")} if module_prepare else {}), }, "saved_state": saved_state, "repository": repository_gate, + "support": support_gate, + **({"owner_resolution": payload["owner_resolution"]} if isinstance(payload.get("owner_resolution"), dict) else {}), "guards": guards, "plan": { "method": METADATA_WRITE_PLAN_METHOD, @@ -41126,18 +46603,384 @@ def metadata_write_preflight(payload: dict[str, Any]) -> dict[str, Any]: } +def metadata_scheduled_job_schedule_write(payload: dict[str, Any]) -> dict[str, Any]: + method = METADATA_WRITE_METHOD + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + target = payload.get("target") if isinstance(payload.get("target"), dict) else {} + mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() + if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: + return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) + allow_write, allow_write_error = strict_bool_argument(payload, "allow_saved_state_write", method=method, default=False) + if allow_write_error: + return allow_write_error + include_storage, include_storage_error = strict_include_storage(payload, method) + if include_storage_error: + return include_storage_error + timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1) + if timeout_error: + return timeout_error + timeout_seconds = int(timeout_seconds or 30) + table = str(target.get("table") or payload.get("table") or "ConfigSave") + if table != "ConfigSave": + return invalid_argument(method, "target.table", "Scheduled-job schedule writes currently target ConfigSave only.", allowed_values=["ConfigSave"]) + schedule = payload.get("schedule") + if schedule is None: + schedule = target.get("schedule") + if not isinstance(schedule, dict) or not schedule: + return invalid_argument(method, "schedule", "Pass schedule as a non-empty JSON object with named schedule fields.") + + ref = str(target.get("ref") or payload.get("ref") or "").strip() + requested_kind = None + ref_name = "" + if ref: + requested_kind, ref_name = parse_object_query(None, ref) + if requested_kind != "ScheduledJob" or not ref_name: + return invalid_argument(method, "target.ref", "Use a scheduled-job reference such as РегламентныеЗадания.ОбменДанными.") + guid = str(target.get("guid") or target.get("object_guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower() + if guid and not is_guid_text(guid): + return invalid_argument(method, "target.guid", "guid must be a GUID string.") + name = str( + ref_name + or target.get("name") + or target.get("object") + or target.get("object_name") + or payload.get("object_name") + or payload.get("name") + or "" + ).strip() + selector = guid or name + if not selector: + return invalid_argument(method, "target", "Pass target.ref, target.name, or target.guid for the scheduled job.") + + object_result = get_object( + "ScheduledJob", + selector, + base_id=base_id, + view="effective", + limit=int(payload.get("limit") or 20), + include_storage=True, + include_semantic=False, + timeout_seconds=timeout_seconds, + table=table, + ) + if object_result.get("status") != "ok": + result = dict(object_result) + result.update({"schema": "onec_scheduled_job_schedule_write.v1", "method": method, "execution_mode": mode}) + return result + object_card = object_result.get("object") if isinstance(object_result.get("object"), dict) else {} + object_guid = str(object_card.get("guid") or guid).lower() + if not is_guid_text(object_guid): + return { + "schema": "onec_scheduled_job_schedule_write.v1", + "method": method, + "status": "blocked", + "error": "scheduled_job_guid_unresolved", + "base_id": base_id, + "object": public_metadata_row(object_card, include_storage=bool(include_storage)), + "diagnostics": {"message": "The scheduled job was found, but its public metadata GUID could not be resolved."}, + } + file_name = str(target.get("file_name") or payload.get("file_name") or f"{object_guid}.0") + if Path(file_name).name != file_name or file_name != f"{object_guid}.0": + return invalid_argument(method, "target.file_name", "The scheduled-job schedule file must be .0.") + data, config, read_error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=timeout_seconds) + if read_error: + return { + "schema": "onec_scheduled_job_schedule_write.v1", + "method": method, + "status": "needs_prepare", + "execution_mode": mode, + "base_id": base_id, + "object": public_metadata_row(object_card, include_storage=bool(include_storage)), + "diagnostics": { + "message": "No saved-state schedule payload was found. Prepare the scheduled job in ConfigSave before writing its schedule.", + "source_boundary": "The adapter does not write active Config rows.", + }, + "next_call": { + "method": "metadata.saved_state.prepare", + "params": { + "base_id": base_id, + "ref": object_card.get("ref") or object_selector_ref("ScheduledJob", str(object_card.get("name") or name)), + "layer": "base_saved_state", + }, + }, + } + tree = parse_config_tree_from_bytes(data or b"") + edit_plan = scheduled_job_schedule_write_edits(tree, schedule) + if edit_plan.get("status") != "ok": + return { + "schema": "onec_scheduled_job_schedule_write.v1", + "method": method, + "execution_mode": mode, + "base_id": base_id, + "object": public_metadata_row(object_card, include_storage=bool(include_storage)), + **edit_plan, + } + public_object = public_metadata_row(object_card, include_storage=bool(include_storage)) + source = { + "kind": "live_sql", + **({"database": config.get("database")} if isinstance(config, dict) and config.get("database") and include_storage else {}), + **({"table": table, "file_name": file_name} if include_storage else {}), + } + result: dict[str, Any] = { + "schema": "onec_scheduled_job_schedule_write.v1", + "method": method, + "status": "blocked" if not allow_write else ("unchanged" if not edit_plan["edits"] else "planned"), + "execution_mode": mode, + "base_id": base_id, + "object": public_object, + "source": source, + "current_schedule": edit_plan["current"], + "requested_schedule": edit_plan["requested"], + "counts": edit_plan["counts"], + "write_mode": { + "requested": "saved_state", + "target_table": table, + "sql_write_performed": False, + "requires_apply_gate": True, + }, + } + if not allow_write: + result["diagnostics"] = { + "message": "Schedule write planning is opt-in; pass allow_saved_state_write=true.", + "read_only": True, + } + result["next_call"] = { + "method": method, + "params": { + "base_id": base_id, + "target": {"kind": "schedule", "ref": public_object.get("ref")}, + "schedule": edit_plan["requested"], + "allow_saved_state_write": True, + "execution_mode": "plan", + }, + } + return result + if not edit_plan["edits"]: + result["diagnostics"] = {"message": "The requested schedule already matches the saved-state payload."} + return result + + proposal = changes_propose( + { + "base_id": base_id, + "source": { + "base_id": base_id, + "table": table, + "file_name": file_name, + "expected_sha1": str(payload.get("expected_sha1") or hashlib.sha1(data or b"").hexdigest()), + }, + "edits": [{key: value for key, value in edit.items() if key != "field"} for edit in edit_plan["edits"]], + "preserve_format": True, + "include_payload": bool(payload.get("include_payload") is True or mode != "plan"), + "include_text": bool(payload.get("include_text") is True), + "timeout_seconds": timeout_seconds, + "summary": payload.get("summary") or f"Update scheduled-job schedule {public_object.get('ref') or object_guid}", + } + ) + result["field_edits"] = [ + {key: value for key, value in edit.items() if key != "replace_root"} + for edit in edit_plan["edits"] + ] + result["proposal"] = proposal if mode == "plan" else sanitize_proposal_for_response(proposal) + if proposal.get("status") not in {"accepted_for_review", "ok"}: + result["status"] = proposal.get("status") or "error" + result["diagnostics"] = proposal.get("diagnostics") + return result + if mode == "plan": + return result + + allow_apply, allow_apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) + if allow_apply_error: + return allow_apply_error + if not allow_apply: + return invalid_argument(method, "allow_sql_saved_state_apply", "Apply mode is opt-in; pass allow_sql_saved_state_apply=true.") + apply_result = storage_saved_state_apply_proposal( + { + **repository_write_context(payload), + "base_id": base_id, + "allow_sql_saved_state_apply": True, + "proposal": proposal, + "timeout_seconds": timeout_seconds, + } + ) + result["apply_result"] = apply_result + result["status"] = apply_result.get("status") or "error" + result["applied"] = bool(apply_result.get("applied")) + result["write_mode"]["sql_write_performed"] = bool(result["applied"]) + if result["applied"]: + readback, _, readback_error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=timeout_seconds) + readback_schedule = scheduled_job_sql_schedule(parse_config_tree_from_bytes(readback or b""), include_storage=False) if not readback_error else {"status": "error"} + checks = [ + {"field": field, "expected": expected, "actual": readback_schedule.get(field), "ok": readback_schedule.get(field) == expected} + for field, expected in edit_plan["requested"].items() + ] + result["semantic_verification"] = { + "status": "ok" if checks and all(check["ok"] for check in checks) else "mismatch", + "schedule": readback_schedule, + "checks": checks, + } + if mode in {"apply", "apply_and_verify"} and all(check["ok"] for check in checks): + result["status"] = "verified" + if mode in {"apply", "apply_and_verify"}: + return result + + allow_rollback, allow_rollback_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) + if allow_rollback_error: + return allow_rollback_error + if not allow_rollback: + return invalid_argument(method, "allow_sql_saved_state_rollback", "apply_and_rollback mode is opt-in; pass allow_sql_saved_state_rollback=true.") + backup_id = ((apply_result.get("backup") or {}).get("backup_id") if isinstance(apply_result.get("backup"), dict) else None) + if not backup_id: + result["status"] = "rollback_unavailable" + result["diagnostics"] = {"message": "Apply result did not return backup.backup_id; cannot rollback automatically."} + return result + rollback_result = storage_saved_state_rollback( + { + **repository_write_context(payload), + "base_id": base_id, + "allow_sql_saved_state_rollback": True, + "backup_id": backup_id, + "timeout_seconds": timeout_seconds, + } + ) + result["rollback_result"] = rollback_result + result["rolled_back"] = bool(rollback_result.get("applied")) + result["status"] = "verified_and_rolled_back" if result["applied"] and result["rolled_back"] else ("applied_rollback_failed" if result["applied"] else result["status"]) + return result + + def metadata_write(payload: dict[str, Any]) -> dict[str, Any]: method = METADATA_WRITE_METHOD mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() + payload, context_error = normalize_repository_write_context(payload, method) + if context_error: + return {"schema": "onec_metadata_write.v1", "method": method, "base_id": payload.get("base_id"), **context_error} + payload = resolve_write_gate_context(payload) repository_error = repository_apply_gate(payload, method, mode) if repository_error: return repository_error payload = metadata_write_save_first_payload(payload, mode) target = payload.get("target") if isinstance(payload.get("target"), dict) else {} - explicit_target_kind = payload.get("target_kind") or payload.get("kind") or target.get("kind") or target.get("area") or payload.get("area") + intent = payload.get("intent") if isinstance(payload.get("intent"), dict) else {} + explicit_route_kind = payload.get("target_kind") or target.get("target_kind") or target.get("area") or payload.get("area") + if not explicit_route_kind and str(target.get("kind") or "").strip().casefold() in { + "form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные" + }: + explicit_route_kind = target.get("kind") + if not explicit_route_kind and str(payload.get("kind") or "").strip().casefold() in {"form", "форма", "module", "модуль", "bsl"}: + explicit_route_kind = payload.get("kind") + explicit_target_kind = explicit_route_kind target_kind = str(explicit_target_kind or "form").strip().casefold() + if metadata_write_is_schedule_target(payload): + result = metadata_scheduled_job_schedule_write(payload) + result.setdefault("routed_method", "metadata.write:scheduled_job_schedule") + result.setdefault("target_kind", "schedule") + return result + if target_kind in {"object", "объект", "metadata", "метаданные"}: + object_operation = str( + first_non_empty_arg(payload, "operation", default=target.get("operation") or intent.get("operation")) or "" + ).strip().casefold() + if object_operation in {"add_attribute", "attribute_add", "добавить_реквизит", "добавитьреквизит"}: + add_payload: dict[str, Any] = { + **payload, + "template_member_ref": first_non_empty_arg( + payload, + "template_member_ref", + default=target.get("template_member_ref") or intent.get("template_member_ref"), + ), + "new_member_name": first_non_empty_arg( + payload, + "new_member_name", + default=target.get("new_member_name") or intent.get("new_member_name"), + ), + "allow_saved_state_write": True, + "execution_mode": mode, + } + for optional_key in ("new_member_synonym", "new_member_comment"): + if optional_key not in add_payload: + optional_value = target.get(optional_key) if optional_key in target else intent.get(optional_key) + if optional_value is not None: + add_payload[optional_key] = optional_value + add_payload.pop("target", None) + add_payload.pop("intent", None) + validation_error = validate_metadata_object_member_add_payload(add_payload) + if validation_error: + return validation_error + add_result = metadata_object_member_add(add_payload) + return { + "schema": "onec_metadata_write.v1", + "method": method, + "status": add_result.get("status"), + "execution_mode": mode, + "target_kind": "object", + "operation": "add_attribute", + "base_id": payload.get("base_id"), + "routed_method": OBJECT_MEMBER_ADD_METHOD, + "result": add_result, + } + property_name = first_non_empty_arg(payload, "property", default=target.get("property") or intent.get("property")) + value_present = "value" in payload or "value" in target or "value" in intent or "new" in intent + requested_value = ( + payload.get("value") + if "value" in payload + else target.get("value") + if "value" in target + else intent.get("value") + if "value" in intent + else intent.get("new") + ) + object_payload: dict[str, Any] = { + **payload, + "property": property_name, + "allow_saved_state_write": True, + "execution_mode": mode, + } + if value_present: + object_payload["value"] = requested_value + for selector_key in ( + "ref", + "kind", + "name", + "guid", + "object_type", + "object_name", + "object_guid", + "extension", + "layer", + "member_ref", + "child_ref", + "member_kind", + "member_name", + "canonical_path", + ): + if target.get(selector_key) not in {None, ""}: + object_payload[selector_key] = target.get(selector_key) + if target.get("object_kind") not in {None, ""}: + object_payload["kind"] = target.get("object_kind") + if target.get("object_ref") not in {None, ""}: + object_payload["ref"] = target.get("object_ref") + object_payload.pop("target", None) + object_payload.pop("intent", None) + if str(object_payload.get("kind") or "").strip().casefold() in {"object", "объект", "metadata", "метаданные"}: + object_payload.pop("kind", None) + validation_error = validate_metadata_object_property_write_payload(object_payload) + if validation_error: + return validation_error + object_result = metadata_object_property_write(object_payload) + return { + "schema": "onec_metadata_write.v1", + "method": method, + "status": object_result.get("status"), + "execution_mode": mode, + "target_kind": "object", + "base_id": payload.get("base_id"), + "routed_method": OBJECT_PROPERTY_WRITE_METHOD, + "result": object_result, + } if target_kind not in {"form", "форма", "module", "модуль", "bsl"}: - return invalid_argument(method, "target.kind", "Only form and module saved-state writes are currently routed.", allowed_values=["form", "module"]) + return invalid_argument(method, "target.kind", "Only form, module, object-property, and scheduled-job schedule saved-state writes are currently routed.", allowed_values=["form", "module", "object", "schedule"]) requested_path = str(target.get("canonical_path") or payload.get("canonical_path") or target.get("path") or payload.get("path") or "").strip() path_plan = metadata_write_plan({**payload, "resolve_origin": False}) if requested_path else None planned_target_kind = metadata_write_plan_target_kind(path_plan) @@ -41327,6 +47170,9 @@ def code_write_public_target(payload: dict[str, Any], target: dict[str, Any]) -> def code_write_metadata_payload(payload: dict[str, Any], *, mode: str) -> dict[str, Any]: target = dict(payload.get("target") or {}) target.setdefault("kind", "module") + for source_key, target_key in (("kind", "object_type"), ("name", "object_name"), ("guid", "object_guid")): + if payload.get(source_key) is not None and target.get(target_key) is None: + target[target_key] = payload.get(source_key) for key in ( "canonical_path", "path", @@ -41392,8 +47238,20 @@ def code_write_metadata_payload(payload: dict[str, Any], *, mode: str) -> dict[s def code_write(payload: dict[str, Any]) -> dict[str, Any]: method = CODE_WRITE_METHOD + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload mode = str(payload.get("execution_mode") or payload.get("mode") or "apply").strip().casefold() - write_payload = code_write_metadata_payload(payload, mode=mode) + payload, context_error = normalize_repository_write_context(payload, method) + if context_error: + return { + "schema": "onec_code_write.v1", "method": method, "status": "blocked", + "execution_mode": mode, "base_id": payload.get("base_id"), "applied": False, + "write_mode": {"target": "saved_state", "activation_state": "not_activated", "production_apply": False}, + **context_error, + } + resolved_payload = resolve_write_gate_context(payload) + write_payload = code_write_metadata_payload(resolved_payload, mode=mode) target = write_payload.get("target") if isinstance(write_payload.get("target"), dict) else {} metadata_result = metadata_write(write_payload) nested_result = metadata_result.get("result") if isinstance(metadata_result.get("result"), dict) else {} @@ -41453,6 +47311,8 @@ def code_write(payload: dict[str, Any]) -> dict[str, Any]: response["_history_evidence"] = {"backup_ids": backup_ids} if payload.get("include_storage") is True: response["metadata_write"] = metadata_result + if isinstance(resolved_payload.get("owner_resolution"), dict): + response["owner_resolution"] = resolved_payload["owner_resolution"] return response @@ -41566,6 +47426,9 @@ def form_write_matrix_build_entry(profile: dict[str, Any], requested_target: dic def metadata_form_write_matrix_build(payload: dict[str, Any]) -> dict[str, Any]: method = FORM_WRITE_MATRIX_BUILD_METHOD + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error @@ -41577,9 +47440,12 @@ def metadata_form_write_matrix_build(payload: dict[str, Any]) -> dict[str, Any]: if isinstance(resolved_source, dict): return resolved_source table, file_name = resolved_source + decode_payload = dict(payload) + for owner_selector_key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid"): + decode_payload.pop(owner_selector_key, None) decoded = metadata_form_decode( { - **payload, + **decode_payload, "base_id": base_id, "table": table, "file_name": file_name, @@ -41664,6 +47530,9 @@ def metadata_form_write_matrix_build(payload: dict[str, Any]) -> dict[str, Any]: def metadata_form_write_matrix_smoke(payload: dict[str, Any]) -> dict[str, Any]: method = FORM_WRITE_MATRIX_SMOKE_METHOD + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error @@ -41800,6 +47669,9 @@ def write_learning_resolve_file(payload: dict[str, Any], *, method: str, timeout def metadata_write_learning_capture(payload: dict[str, Any], stage: str) -> dict[str, Any]: method = f"metadata.write_learning.capture_{stage}" + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error @@ -41811,9 +47683,12 @@ def metadata_write_learning_capture(payload: dict[str, Any], stage: str) -> dict if isinstance(resolved_source, dict): return resolved_source table, file_name = resolved_source + decode_payload = dict(payload) + for owner_selector_key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid"): + decode_payload.pop(owner_selector_key, None) decoded = metadata_form_decode( { - **payload, + **decode_payload, "base_id": base_id, "table": table, "file_name": file_name, @@ -42223,29 +48098,123 @@ def adapter_now() -> float: def adapter_job_store_path() -> Path: + """Legacy JSON path used only for one-time migration to local SQLite.""" + return Path(os.environ.get("ONEC_ADAPTER_JOB_STORE") or str(cache_db_path().with_name("adapter-jobs.json"))) +def adapter_state_db_path() -> Path: + """Local adapter state; never points at a configured 1C database.""" + + return Path(os.environ.get("ONEC_ADAPTER_STATE_DB") or str(cache_db_path())) + + +def adapter_state_connection() -> sqlite3.Connection: + path = adapter_state_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(path, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS adapter_jobs ( + job_id TEXT PRIMARY KEY, + method TEXT, + status TEXT NOT NULL, + updated_at REAL NOT NULL, + payload_json TEXT NOT NULL + ) + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_adapter_jobs_updated_at ON adapter_jobs(updated_at)") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS adapter_state_meta ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at REAL NOT NULL + ) + """ + ) + conn.commit() + return conn + + +def adapter_load_legacy_jobs() -> dict[str, dict[str, Any]]: + path = adapter_job_store_path() + if not path.is_file(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8-sig")) + except Exception: + return {} + jobs = payload.get("jobs") if isinstance(payload, dict) else None + if not isinstance(jobs, dict): + return {} + return {str(job_id): dict(job) for job_id, job in jobs.items() if isinstance(job, dict)} + + +ADAPTER_STATE_SECRET_KEYS = { + "password", + "newpassword", + "oldpassword", + "sqlpassword", + "repositorypassword", + "token", + "servicetoken", + "accesstoken", + "refreshtoken", + "authorization", + "proxyauthorization", + "clientsecret", +} + + +def adapter_state_safe_value(value: Any) -> Any: + """Drop clear credentials before writing adapter-owned local state.""" + + if isinstance(value, dict): + return { + key: adapter_state_safe_value(item) + for key, item in value.items() + if normalize(key) not in ADAPTER_STATE_SECRET_KEYS + } + if isinstance(value, list): + return [adapter_state_safe_value(item) for item in value] + return value + + def adapter_load_jobs_from_store() -> None: global ADAPTER_JOB_STORE_LOADED if ADAPTER_JOB_STORE_LOADED: return - path = adapter_job_store_path() ADAPTER_JOB_STORE_LOADED = True - if not path.is_file(): - return + jobs: dict[str, dict[str, Any]] = {} try: - payload = json.loads(path.read_text(encoding="utf-8-sig")) + with adapter_state_connection() as conn: + rows = conn.execute("SELECT job_id, payload_json FROM adapter_jobs ORDER BY updated_at").fetchall() + for row in rows: + try: + job = json.loads(str(row["payload_json"] or "")) + except Exception: + continue + if isinstance(job, dict): + jobs[str(row["job_id"])] = job + migration_row = conn.execute("SELECT value FROM adapter_state_meta WHERE key='legacy_jobs_imported'").fetchone() + if not migration_row: + if not jobs: + jobs.update(adapter_load_legacy_jobs()) + conn.execute( + "INSERT OR REPLACE INTO adapter_state_meta(key, value, updated_at) VALUES('legacy_jobs_imported', ?, ?)", + ("yes", adapter_now()), + ) + conn.commit() except Exception: return - jobs = payload.get("jobs") if isinstance(payload, dict) else None - if not isinstance(jobs, dict): - return now = adapter_now() with ADAPTER_JOB_LOCK: for job_id, job in jobs.items(): - if not isinstance(job, dict): - continue restored = dict(job) if restored.get("status") in {"queued", "running"}: restored.update( @@ -42264,32 +48233,30 @@ def adapter_load_jobs_from_store() -> None: restored["updated_at"] = now restored["current_step"] = "adapter_restarted" ADAPTER_JOBS[str(job_id)] = restored + if jobs: + adapter_save_jobs_to_store() def adapter_save_jobs_to_store() -> None: - path = adapter_job_store_path() try: - path.parent.mkdir(parents=True, exist_ok=True) - serializable_jobs = { - job_id: job + rows = [ + ( + str(job_id), + str(job.get("method") or ""), + str(job.get("status") or "unknown"), + float(job.get("updated_at") or job.get("created_at") or adapter_now()), + json.dumps(adapter_state_safe_value(job), ensure_ascii=False, separators=(",", ":"), default=str), + ) for job_id, job in ADAPTER_JOBS.items() if isinstance(job, dict) - } - path.write_text( - json.dumps( - { - "schema": "onec_adapter_job_store.v1", - "adapter_instance_id": ADAPTER_INSTANCE_ID, - "updated_at": adapter_now(), - "jobs": serializable_jobs, - }, - ensure_ascii=False, - indent=2, - default=str, + ] + with adapter_state_connection() as conn: + conn.execute("DELETE FROM adapter_jobs") + conn.executemany( + "INSERT INTO adapter_jobs(job_id, method, status, updated_at, payload_json) VALUES(?, ?, ?, ?, ?)", + rows, ) - + "\n", - encoding="utf-8", - ) + conn.commit() except Exception: return @@ -42346,6 +48313,225 @@ def adapter_job_heartbeat_seconds() -> float: return 2.0 +def adapter_job_process_isolation_enabled(method: str) -> bool: + if not truthy(os.environ.get("ONEC_ADAPTER_JOB_PROCESS_ISOLATION", "true")): + return False + return not method.startswith(("adapter.job.", "repository.")) + + +def adapter_job_process_memory_limit_bytes() -> int: + try: + megabytes = max(0, int(os.environ.get("ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB", "0"))) + except ValueError: + return 0 + return megabytes * 1024 * 1024 + + +def adapter_job_process_cpu_limit_seconds() -> int: + try: + return max(0, int(os.environ.get("ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS", "0"))) + except ValueError: + return 0 + + +def adapter_apply_job_process_limits() -> dict[str, Any]: + """Apply optional POSIX hard limits inside an isolated worker process.""" + + limits = { + "memory_bytes": adapter_job_process_memory_limit_bytes(), + "cpu_seconds": adapter_job_process_cpu_limit_seconds(), + "applied": [], + "unsupported": [], + } + try: + import resource + except ImportError: + if limits["memory_bytes"] or limits["cpu_seconds"]: + limits["unsupported"].append("resource_module") + return limits + if limits["memory_bytes"]: + try: + resource.setrlimit(resource.RLIMIT_AS, (limits["memory_bytes"], limits["memory_bytes"])) + limits["applied"].append("memory") + except (OSError, ValueError): + limits["unsupported"].append("memory") + if limits["cpu_seconds"]: + try: + resource.setrlimit(resource.RLIMIT_CPU, (limits["cpu_seconds"], limits["cpu_seconds"])) + limits["applied"].append("cpu") + except (OSError, ValueError): + limits["unsupported"].append("cpu") + return limits + + +def adapter_isolated_job_entry( + job_id: str, + method: str, + job_payload: dict[str, Any], + timeout_seconds: float, + event_queue: Any, +) -> None: + """Child-process entry point. All progress is sent back to the parent.""" + + global ADAPTER_JOB_EVENT_SINK + ADAPTER_JOB_EVENT_SINK = event_queue + now = adapter_now() + ADAPTER_JOBS[job_id] = { + "schema": "onec_adapter_job.v1", + "status": "running", + "job_id": job_id, + "method": method, + "base_id": job_payload.get("base_id"), + "created_at": now, + "started_at": now, + "updated_at": now, + } + try: + event_queue.put({"type": "process_started", "pid": os.getpid(), "limits": adapter_apply_job_process_limits()}) + if method == "metadata.object.full": + adapter_run_metadata_object_full_job(job_id, job_payload, timeout_seconds) + return + if method == "metadata.object.attributes": + adapter_run_metadata_object_attributes_job(job_id, job_payload, timeout_seconds) + return + if method == "metadata.object.special.details" and canonical_kind(str(job_payload.get("kind") or "")) == "DocumentJournal": + adapter_run_document_journal_special_job(job_id, job_payload, timeout_seconds) + return + if method == "metadata.cache.rebuild": + adapter_run_metadata_cache_rebuild_job(job_id, job_payload, timeout_seconds) + return + result = call_method_impl(method, job_payload) + event_queue.put({"type": "result", "result": result}) + except BaseException as exc: + event_queue.put( + { + "type": "error", + "error": adapter_public_error( + method, + "adapter_job_process_exception", + {"message": str(exc), "traceback": traceback.format_exc(limit=8)}, + ), + } + ) + finally: + try: + event_queue.put({"type": "process_exited"}) + except Exception: + pass + + +def adapter_stop_job_process(process: Any) -> None: + if not process.is_alive(): + process.join(timeout=0.2) + return + process.terminate() + process.join(timeout=2) + if process.is_alive() and hasattr(process, "kill"): + process.kill() + process.join(timeout=2) + + +def adapter_apply_isolated_job_event(job_id: str, event: dict[str, Any]) -> bool: + event_type = str(event.get("type") or "") + if event_type == "set": + adapter_job_set(job_id, **dict(event.get("updates") or {})) + return False + if event_type == "finish": + adapter_job_finish(job_id, str(event.get("status") or "error"), **dict(event.get("updates") or {})) + return True + if event_type == "result": + adapter_job_finish( + job_id, + "done", + result=event.get("result"), + progress={"current_step": "done", "completed_steps": 1, "total_steps": 1, "percent": 100}, + ) + return True + if event_type == "error": + adapter_job_finish(job_id, "error", **dict(event.get("error") or {})) + return True + if event_type == "process_started": + adapter_job_set( + job_id, + worker={ + "mode": "process", + "pid": event.get("pid"), + "limits": event.get("limits") or {}, + }, + ) + return False + + +def adapter_run_job_in_process( + job_id: str, + method: str, + job_payload: dict[str, Any], + timeout_seconds: float, +) -> None: + context = multiprocessing.get_context("spawn") + event_queue = context.Queue() + process = context.Process( + target=adapter_isolated_job_entry, + args=(job_id, method, job_payload, timeout_seconds, event_queue), + name=f"onec-adapter-process-{job_id[:8]}", + daemon=True, + ) + process.start() + deadline = adapter_now() + timeout_seconds + terminal_event = False + try: + while True: + if adapter_job_cancel_requested(job_id): + adapter_stop_job_process(process) + return + remaining = deadline - adapter_now() + if remaining <= 0: + adapter_stop_job_process(process) + adapter_job_finish( + job_id, + "error", + **adapter_public_error( + method, + "job_timeout", + {"message": f"Adapter job process exceeded {timeout_seconds:.0f} seconds and was terminated."}, + ), + ) + return + try: + event = event_queue.get(timeout=min(0.25, remaining)) + except queue.Empty: + if process.is_alive(): + continue + break + except (EOFError, OSError): + break + if isinstance(event, dict): + terminal_event = adapter_apply_isolated_job_event(job_id, event) or terminal_event + if terminal_event and not process.is_alive(): + break + process.join(timeout=0.5) + while True: + try: + event = event_queue.get_nowait() + except (queue.Empty, EOFError, OSError): + break + if isinstance(event, dict): + terminal_event = adapter_apply_isolated_job_event(job_id, event) or terminal_event + if not terminal_event and not adapter_job_cancel_requested(job_id): + adapter_job_finish( + job_id, + "error", + **adapter_public_error( + method, + "adapter_job_process_exited", + {"message": f"Adapter job process exited without a result (exit_code={process.exitcode})."}, + ), + ) + finally: + adapter_stop_job_process(process) + event_queue.close() + + def adapter_cleanup_jobs(now: float | None = None) -> None: adapter_load_jobs_from_store() effective_now = adapter_now() if now is None else now @@ -42369,7 +48555,13 @@ def adapter_job_set(job_id: str, **updates: Any) -> None: if job: job.update(updates) job["updated_at"] = adapter_now() - adapter_save_jobs_to_store() + if ADAPTER_JOB_EVENT_SINK is not None: + try: + ADAPTER_JOB_EVENT_SINK.put({"type": "set", "updates": updates}) + except Exception: + pass + else: + adapter_save_jobs_to_store() def adapter_job_cancel_requested(job_id: str) -> bool: @@ -42378,20 +48570,26 @@ def adapter_job_cancel_requested(job_id: str) -> bool: return truthy(job.get("cancel_requested")) or job.get("status") == "cancelled" -def adapter_job_finish(job_id: str, status: str, **updates: Any) -> None: +def adapter_job_finish(job_id: str, job_status: str, **updates: Any) -> None: with ADAPTER_JOB_LOCK: job = ADAPTER_JOBS.get(job_id) - if not job or (job.get("status") == "cancelled" and status != "cancelled"): + if not job or (job.get("status") == "cancelled" and job_status != "cancelled"): return job.update(updates) - job["status"] = status - if status in {"done", "cancelled"} and "current_step" not in updates: - job["current_step"] = status - elif status == "error" and "current_step" not in updates: + job["status"] = job_status + if job_status in {"done", "cancelled"} and "current_step" not in updates: + job["current_step"] = job_status + elif job_status == "error" and "current_step" not in updates: job["current_step"] = "error" job["finished_at"] = adapter_now() job["updated_at"] = job["finished_at"] - adapter_save_jobs_to_store() + if ADAPTER_JOB_EVENT_SINK is not None: + try: + ADAPTER_JOB_EVENT_SINK.put({"type": "finish", "status": job_status, "updates": updates}) + except Exception: + pass + else: + adapter_save_jobs_to_store() def adapter_card_failure_result_for_long_method(method: str, payload: dict[str, Any], card_result: dict[str, Any], elapsed_seconds: float) -> dict[str, Any] | None: @@ -43190,6 +49388,12 @@ def adapter_run_document_journal_special_job(job_id: str, payload: dict[str, Any ) +def adapter_full_forms_method(payload: dict[str, Any]) -> str: + if truthy(payload.get("include_form_details")) or truthy(payload.get("include_form_module_text")): + return "metadata.object.form.details" + return "metadata.object.forms" + + def adapter_run_metadata_object_full_job(job_id: str, payload: dict[str, Any], timeout_seconds: float) -> None: partial = adapter_full_partial_result(payload) started_at = adapter_now() @@ -43215,7 +49419,7 @@ def adapter_run_metadata_object_full_job(job_id: str, payload: dict[str, Any], t "semantic": "metadata.object.attributes", "modules": "metadata.object.modules", "templates": "metadata.object.template.details" if truthy(payload.get("include_template_details")) else "metadata.object.templates", - "forms": "metadata.object.form.details", + "forms": adapter_full_forms_method(payload), "commands": "metadata.object.commands", "parts_summary": "metadata.object.parts", } @@ -44275,6 +50479,10 @@ def validate_code_write_payload(payload: dict[str, Any]) -> dict[str, Any] | Non payload, method, [ + "ref", + "kind", + "name", + "guid", "canonical_path", "path", "object_type", @@ -44520,11 +50728,18 @@ def validate_templates_read_payload(payload: dict[str, Any], method: str = "temp def validate_templates_areas_find_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "templates.areas.find" + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload payload = normalize_template_route_ref_payload(payload) base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error - string_error = validate_optional_string_arguments(payload, method, ["query", "template", "name_filter", "name", "object_name", "extension", "table", "file_name", "part_id", "route_ref", "area_query", "area", "area_name", "area_match"]) + string_error = validate_optional_string_arguments( + payload, + method, + ["ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", "query", "template", "name_filter", "extension", "table", "file_name", "part_id", "route_ref", "area_query", "area", "area_name", "area_match"], + ) if string_error: return string_error area_match = str(payload.get("area_match") or "").strip().lower() @@ -44929,7 +51144,32 @@ def validate_metadata_saved_state_forms_search_payload(payload: dict[str, Any]) _, scan_limit_error = parse_int_argument(payload, "scan_limit", method=method, default=1000, minimum=1, maximum=5000) if scan_limit_error: return scan_limit_error - string_error = validate_optional_string_arguments(payload, method, ["form", "form_name", "name_filter", "element", "element_name", "command", "attribute", "query", "text", "prefix", "extension_guid"]) + string_error = validate_optional_string_arguments( + payload, + method, + [ + "ref", + "kind", + "name", + "guid", + "object_type", + "object_name", + "object_guid", + "form", + "form_name", + "form_guid", + "name_filter", + "element", + "element_name", + "command", + "attribute", + "query", + "text", + "prefix", + "extension", + "extension_guid", + ], + ) if string_error: return string_error if "tables" in payload and not (isinstance(payload.get("tables"), list) and all(isinstance(item, str) for item in payload.get("tables") or [])): @@ -44946,6 +51186,7 @@ def validate_metadata_saved_state_prepare_payload(payload: dict[str, Any]) -> di payload, method, [ + "layer", "target_table", "source_table", "table", @@ -44966,6 +51207,16 @@ def validate_metadata_saved_state_prepare_payload(payload: dict[str, Any]) -> di ) if string_error: return string_error + layer = str(payload.get("layer") or "").strip() + if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: + return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) + target_table = str(payload.get("target_table") or payload.get("table") or "").strip() + if target_table and target_table not in SAVED_STATE_SOURCE_BY_TARGET: + return invalid_argument(method, "target_table", "target_table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) + if layer and target_table and target_table != SAVED_STATE_TABLE_BY_LAYER[layer]: + return invalid_argument(method, "layer", "layer conflicts with target_table.") + if layer == "base_saved_state" and str(payload.get("extension") or "").strip(): + return invalid_argument(method, "layer", "Extension object preparation requires extension_saved_state.", allowed_values=["extension_saved_state"]) if "file_names" in payload and payload.get("file_names") is not None: file_names = payload.get("file_names") if not isinstance(file_names, list) or not all(isinstance(item, str) for item in file_names): @@ -44981,7 +51232,10 @@ def validate_metadata_saved_state_prepare_payload(payload: dict[str, Any]) -> di if part_limit_error: return part_limit_error _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) - return timeout_error + if timeout_error: + return timeout_error + _, include_storage_error = strict_include_storage(payload, method) + return include_storage_error def validate_metadata_saved_state_status_payload(payload: dict[str, Any]) -> dict[str, Any] | None: @@ -44989,12 +51243,17 @@ def validate_metadata_saved_state_status_payload(payload: dict[str, Any]) -> dic base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error - string_error = validate_optional_string_arguments(payload, method, ["table", "target_table", "prefix"]) + string_error = validate_optional_string_arguments(payload, method, ["layer", "table", "target_table", "prefix"]) if string_error: return string_error + layer = str(payload.get("layer") or "").strip() + if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: + return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) table = str(payload.get("table") or payload.get("target_table") or "") if table and table not in SAVED_STATE_SOURCE_BY_TARGET: return invalid_argument(method, "table", "table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) + if layer and table and table != SAVED_STATE_TABLE_BY_LAYER[layer]: + return invalid_argument(method, "layer", "layer conflicts with table.") prefix = str(payload.get("prefix") or "").strip() if prefix and Path(prefix).name != prefix: return invalid_argument(method, "prefix", "prefix must be a safe FileName prefix.") @@ -45009,6 +51268,9 @@ def validate_metadata_saved_state_status_payload(payload: dict[str, Any]) -> dic _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) if bool_error: return bool_error + _, include_storage_error = strict_include_storage(payload, method) + if include_storage_error: + return include_storage_error return None @@ -45020,7 +51282,22 @@ def validate_metadata_saved_state_diff_payload(payload: dict[str, Any]) -> dict[ string_error = validate_optional_string_arguments( payload, method, - ["table", "target_table", "source_table", "file_name", "module_ref", "module_id"], + [ + "table", + "target_table", + "source_table", + "file_name", + "module_ref", + "module_id", + "ref", + "kind", + "name", + "guid", + "object_type", + "object_name", + "object_guid", + "extension", + ], ) if string_error: return string_error @@ -45037,6 +51314,7 @@ def validate_metadata_saved_state_diff_payload(payload: dict[str, Any]) -> dict[ ("include_tree_diff", True), ("include_evidence", False), ("include_payload_diff", False), + ("include_storage", False), ): _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) if bool_error: @@ -45053,6 +51331,17 @@ def validate_metadata_saved_state_diff_payload(payload: dict[str, Any]) -> dict[ file_name = str(payload.get("file_name") or module_file_name or "").strip() if file_name and Path(file_name).name != file_name: return invalid_argument(method, "file_name", "file_name must be a safe FileName value.") + requested_module_ordinal = first_non_empty_arg(payload, "module_ordinal", "module_index", "module_number") + if requested_module_ordinal not in {None, ""}: + _, ordinal_error = parse_ordinal(requested_module_ordinal, method, argument="module_ordinal") + if ordinal_error: + return ordinal_error + if not module_ref and not file_name and not has_object_selector(payload): + return invalid_argument( + method, + "selector", + "Pass a 1C object selector plus module_ordinal, or a generated module_ref/saved-state file_name.", + ) return None @@ -45061,12 +51350,17 @@ def validate_metadata_saved_state_changes_list_payload(payload: dict[str, Any]) base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error - string_error = validate_optional_string_arguments(payload, method, ["table", "target_table", "prefix"]) + string_error = validate_optional_string_arguments(payload, method, ["layer", "table", "target_table", "prefix"]) if string_error: return string_error + layer = str(payload.get("layer") or "").strip() + if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: + return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) table = str(payload.get("table") or payload.get("target_table") or "").strip() if table and table not in SAVED_STATE_SOURCE_BY_TARGET: return invalid_argument(method, "table", "table must be ConfigSave or ConfigCASSave.", allowed_values=sorted(SAVED_STATE_SOURCE_BY_TARGET)) + if layer and table and table != SAVED_STATE_TABLE_BY_LAYER[layer]: + return invalid_argument(method, "layer", "layer conflicts with table.") prefix = str(payload.get("prefix") or "").strip() if prefix and Path(prefix).name != prefix: return invalid_argument(method, "prefix", "prefix must be a safe FileName prefix.") @@ -45085,6 +51379,9 @@ def validate_metadata_saved_state_changes_list_payload(payload: dict[str, Any]) _, group_by_context_error = strict_bool_argument(payload, "group_by_context", method=method, default=False) if group_by_context_error: return group_by_context_error + _, include_storage_error = strict_bool_argument(payload, "include_storage", method=method, default=False) + if include_storage_error: + return include_storage_error _, context_limit_error = parse_int_argument(payload, "context_limit", method=method, default=50, minimum=0, maximum=500) return context_limit_error @@ -45113,12 +51410,22 @@ def validate_metadata_saved_state_modules_search_payload(payload: dict[str, Any] string_error = validate_optional_string_arguments( payload, method, - ["query", "text", "prefix", "owner_guid", "object_type", "object_name", "object_guid", "kind", "name", "guid", "file_name"], + ["query", "text", "prefix", "owner_guid", "ref", "object_type", "object_name", "object_guid", "kind", "name", "guid", "file_name", "extension", "extension_guid", "layer"], ) if string_error: return string_error if "tables" in payload and not (isinstance(payload.get("tables"), list) and all(isinstance(item, str) for item in payload.get("tables") or [])): return invalid_argument(method, "tables", "tables must be an array of strings.") + layer = str(payload.get("layer") or "").strip() + if layer and layer not in {"base_saved_state", "extension_saved_state"}: + return invalid_argument( + method, + "layer", + "Unsupported saved-state layer.", + allowed_values=["base_saved_state", "extension_saved_state"], + ) + if layer and "tables" in payload: + return invalid_argument(method, "tables", "Pass layer or tables, not both.") return None @@ -45144,7 +51451,7 @@ def validate_metadata_form_write_target_resolve_payload(payload: dict[str, Any], string_error = validate_optional_string_arguments( payload, method, - ["table", "file_name", "form_guid", "guid", "form", "form_name", "name_filter", "element", "element_name", "command", "attribute", "element_path", "path", "element_id", "id", "property", "query", "prefix", "extension_guid"], + ["ref", "kind", "name", "object_type", "object_name", "object_guid", "table", "file_name", "form_guid", "guid", "form", "form_name", "name_filter", "element", "element_name", "command", "attribute", "element_path", "path", "element_id", "id", "property", "query", "prefix", "extension", "extension_guid"], ) if string_error: return string_error @@ -45181,6 +51488,133 @@ def validate_metadata_form_element_write_apply_payload(payload: dict[str, Any]) return None +def validate_metadata_object_property_write_payload(payload: dict[str, Any]) -> dict[str, Any] | None: + method = OBJECT_PROPERTY_WRITE_METHOD + payload, member_error = normalize_object_property_member_payload(payload) + if member_error: + return member_error + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + selector_error = validate_object_selector_arguments(payload, method) + if selector_error: + return selector_error + if not has_object_selector(payload): + return invalid_argument(method, "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE_NO_ORDINAL) + string_error = validate_optional_string_arguments( + payload, + method, + [ + "ref", + "kind", + "name", + "guid", + "object_type", + "object_name", + "object_guid", + "member_ref", + "child_ref", + "member_kind", + "member_name", + "canonical_path", + "extension", + "layer", + "property", + "language", + "expected_old", + "expected_sha1", + "execution_mode", + "mode", + "summary", + ], + ) + if string_error: + return string_error + if "property" not in payload or not str(payload.get("property") or "").strip(): + return invalid_argument(method, "property", "property is required.", allowed_values=["synonym", "comment"]) + if not normalize_object_identity_property(payload.get("property")): + raw_property = str(payload.get("property") or "").strip().casefold() + message = ( + "Object rename is intentionally disabled because reference-safe metadata rename rules are not yet proven." + if raw_property in {"name", "имя", "rename", "переименование"} + else "Only synonym and comment are supported." + ) + return invalid_argument(method, "property", message, allowed_values=["synonym", "comment"]) + if "value" not in payload or not isinstance(payload.get("value"), str): + return invalid_argument(method, "value", "value is required and must be a JSON string; an empty string is allowed.") + layer = str(payload.get("layer") or "").strip() + if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: + return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) + mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() + if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: + return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) + for argument, default in ( + ("allow_saved_state_write", False), + ("auto_prepare_saved_state", False), + ("allow_sql_saved_state_prepare", False), + ("allow_sql_saved_state_apply", False), + ("allow_sql_saved_state_rollback", False), + ): + _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) + if bool_error: + return bool_error + _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) + if timeout_error: + return timeout_error + _, include_storage_error = strict_include_storage(payload, method) + return include_storage_error + + +def validate_metadata_object_member_add_payload(payload: dict[str, Any]) -> dict[str, Any] | None: + method = OBJECT_MEMBER_ADD_METHOD + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + string_error = validate_optional_string_arguments( + payload, + method, + [ + "ref", "kind", "name", "guid", "object_type", "object_name", "object_guid", + "template_member_ref", "new_member_name", "new_member_synonym", "new_member_comment", + "extension", "layer", "expected_sha1", "execution_mode", "mode", "summary", + ], + ) + if string_error: + return string_error + template_ref = str(payload.get("template_member_ref") or "").strip() + if not template_ref: + return invalid_argument(method, "template_member_ref", "template_member_ref is required.") + normalized, selector_error = normalize_object_property_member_payload({**payload, "member_ref": template_ref}) + if selector_error: + return selector_error + if canonical_nested_member_kind(normalized.get("member_kind")) != "Attribute": + return invalid_argument(method, "template_member_ref", "template_member_ref must select an existing Attribute.") + new_name = str(payload.get("new_member_name") or "").strip() + if not new_name: + return invalid_argument(method, "new_member_name", "new_member_name is required.") + mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().casefold() + if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: + return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) + layer = str(payload.get("layer") or "").strip() + if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: + return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) + for argument in ( + "allow_saved_state_write", + "auto_prepare_saved_state", + "allow_sql_saved_state_prepare", + "allow_sql_saved_state_apply", + "allow_sql_saved_state_rollback", + ): + _, bool_error = strict_bool_argument(payload, argument, method=method, default=False) + if bool_error: + return bool_error + _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1) + if timeout_error: + return timeout_error + _, include_storage_error = strict_include_storage(payload, method) + return include_storage_error + + def validate_metadata_form_target_move_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = FORM_TARGET_MOVE_METHOD base_id_or_error = require_base_id(payload, method) @@ -45190,11 +51624,19 @@ def validate_metadata_form_target_move_payload(payload: dict[str, Any]) -> dict[ payload, method, [ + "ref", + "kind", + "name", + "object_type", + "object_name", + "object_guid", "table", "file_name", "form_guid", "guid", "form", + "form_name", + "name_filter", "from", "from_element", "from_path", @@ -45215,6 +51657,8 @@ def validate_metadata_form_target_move_payload(payload: dict[str, Any]) -> dict[ "summary", "execution_mode", "mode", + "extension", + "extension_guid", ], ) if string_error: @@ -45266,6 +51710,7 @@ def validate_metadata_form_command_button_write_payload(payload: dict[str, Any]) "command_title", "command_action", "handler", + "handler_name", "handler_routine_operation", "handler_routine_text", "routine_operation", @@ -45441,9 +51886,16 @@ def validate_metadata_write_payload(payload: dict[str, Any]) -> dict[str, Any] | if target is not None and not isinstance(target, dict): return invalid_argument(method, "target", "target must be a JSON object when provided.") target_dict = target if isinstance(target, dict) else {} - target_kind = str(payload.get("target_kind") or payload.get("kind") or target_dict.get("kind") or target_dict.get("area") or payload.get("area") or "form").strip().casefold() - if target_kind not in {"form", "форма", "module", "модуль", "bsl"}: - return invalid_argument(method, "target.kind", "Only form and module saved-state writes are currently routed.", allowed_values=["form", "module"]) + route_kind = payload.get("target_kind") or target_dict.get("target_kind") or target_dict.get("area") or payload.get("area") + if not route_kind and str(target_dict.get("kind") or "").strip().casefold() in { + "form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные" + }: + route_kind = target_dict.get("kind") + if not route_kind and str(payload.get("kind") or "").strip().casefold() in {"form", "форма", "module", "модуль", "bsl"}: + route_kind = payload.get("kind") + target_kind = str(route_kind or "form").strip().casefold() + if target_kind not in {"form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные"}: + return invalid_argument(method, "target.kind", "Only form, module, and object-property saved-state writes are currently routed.", allowed_values=["form", "module", "object"]) mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().lower() if mode not in FORM_ELEMENT_WRITE_APPLY_MODES: return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES)) @@ -45534,7 +51986,7 @@ def validate_metadata_write_learning_payload(payload: dict[str, Any], method: st return validate_optional_string_arguments( payload, method, - ["learning_id", "table", "file_name", "form_guid", "guid", "form", "element", "command", "attribute", "element_name", "element_path", "path", "element_id", "id", "property", "value"], + ["learning_id", "ref", "kind", "name", "object_type", "object_name", "object_guid", "table", "file_name", "form_guid", "guid", "form", "form_name", "name_filter", "element", "command", "attribute", "element_name", "element_path", "path", "element_id", "id", "property", "value", "extension", "extension_guid"], ) if method in {"metadata.write_learning.capture_before", "metadata.write_learning.capture_after"}: base_id_or_error = require_base_id(payload, method) @@ -45549,7 +52001,7 @@ def validate_metadata_write_learning_payload(payload: dict[str, Any], method: st return validate_optional_string_arguments( payload, method, - ["learning_id", "table", "file_name", "form_guid", "guid", "form", "element", "command", "attribute", "element_name", "element_path", "path", "element_id", "id", "property", "value"], + ["learning_id", "ref", "kind", "name", "object_type", "object_name", "object_guid", "table", "file_name", "form_guid", "guid", "form", "form_name", "name_filter", "element", "command", "attribute", "element_name", "element_path", "path", "element_id", "id", "property", "value", "extension", "extension_guid"], ) if method == "metadata.write_learning.diff": return validate_optional_string_arguments(payload, method, ["learning_id", "before_snapshot_id", "after_snapshot_id", "before_path", "after_path"]) @@ -45920,7 +52372,11 @@ def validate_metadata_cache_status_payload(payload: dict[str, Any]) -> dict[str, def validate_metadata_cache_lookup_payload(payload: dict[str, Any]) -> dict[str, Any] | None: - base_id_or_error = require_base_id(payload, "metadata.cache.lookup") + method = "metadata.cache.lookup" + payload = normalize_object_selector_aliases(payload, method) + if isinstance(payload, dict) and payload.get("status") == "invalid_argument": + return payload + base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error for argument in ("guid", "kind", "name"): @@ -45934,6 +52390,51 @@ def validate_metadata_cache_lookup_payload(payload: dict[str, Any]) -> dict[str, return None +def validate_metadata_module_owner_cache_prune_payload(payload: dict[str, Any]) -> dict[str, Any] | None: + method = "metadata.module_owner_cache.prune" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + string_error = validate_optional_string_arguments( + payload, + method, + [ + "owner_guid", + "module_ref", + "ref", + "kind", + "name", + "guid", + "object_type", + "object_name", + "object_guid", + ], + ) + if string_error: + return string_error + owner_guid = str(payload.get("owner_guid") or "").strip() + if owner_guid and not is_guid_text(owner_guid): + return invalid_argument(method, "owner_guid", "owner_guid must be a valid GUID JSON string.") + module_refs = payload.get("module_refs") + if module_refs is not None and ( + not isinstance(module_refs, list) + or any(not isinstance(item, str) or not item.strip() for item in module_refs) + ): + return invalid_argument(method, "module_refs", "module_refs must be an array of non-empty strings.") + if not owner_guid and not str(payload.get("module_ref") or "").strip() and not module_refs and not has_object_selector(payload): + return invalid_argument( + method, + "selector", + "Pass a 1C owner selector (ref or kind/name), owner_guid, module_ref, or module_refs.", + ) + for argument, default in (("dry_run", False), ("include_storage", False)): + _, bool_error = strict_bool_argument(payload, argument, method=method, default=default) + if bool_error: + return bool_error + _, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=300) + return timeout_error + + def validate_semantic_cache_search_payload(payload: dict[str, Any]) -> dict[str, Any] | None: method = "semantic.cache.search" base_id_or_error = require_base_id(payload, method) @@ -46228,7 +52729,7 @@ def validate_adapter_job_payload(method: str, job_payload: dict[str, Any]) -> di selector_argument_error = validate_object_selector_arguments(job_payload, method, include_view=not template_view_method) if selector_argument_error: return selector_argument_error - normalized_payload = normalize_object_selector_aliases(job_payload, method) + normalized_payload = normalize_selector_payload_for_method(job_payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload if normalized_payload is not job_payload: @@ -46244,6 +52745,21 @@ def validate_adapter_job_payload(method: str, job_payload: dict[str, Any]) -> di return ordinal_exists_error if method == "help.methods": return validate_help_methods_payload(job_payload) + if method == "repository.layer.connection.set": + base_id_or_error = require_base_id(job_payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + string_error = validate_optional_string_arguments(job_payload, method, ["layer_id", "extension_guid", "connection_state", "repository_user"]) + if string_error: + return string_error + _, confirmation_error = strict_bool_argument(job_payload, "confirm_repository_connection_change", method=method, default=False) + return confirmation_error + if method == "repository.layers.audit": + base_id_or_error = require_base_id(job_payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + _, timeout_error = parse_int_argument(job_payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=600) + return timeout_error if method in repository_control.METHODS: base_required = method not in {repository_control.METHOD_LOCK_REQUEST_STATUS, repository_control.METHOD_LOCK_REQUEST_CANCEL, repository_control.METHOD_VERIFY, repository_control.METHOD_CLOSE, repository_control.METHOD_UNLOCK, repository_control.METHOD_COMMIT_PLAN, repository_control.METHOD_COMMIT} if base_required: @@ -46365,6 +52881,10 @@ def validate_adapter_job_payload(method: str, job_payload: dict[str, Any]) -> di return validate_metadata_saved_state_modules_search_payload(job_payload) if method == "metadata.form.element.write_apply": return validate_metadata_form_element_write_apply_payload(job_payload) + if method == OBJECT_PROPERTY_WRITE_METHOD: + return validate_metadata_object_property_write_payload(job_payload) + if method == OBJECT_MEMBER_ADD_METHOD: + return validate_metadata_object_member_add_payload(job_payload) if method == FORM_TARGET_MOVE_METHOD: return validate_metadata_form_target_move_payload(job_payload) if method == FORM_COMMAND_BUTTON_WRITE_METHOD: @@ -46387,6 +52907,8 @@ def validate_adapter_job_payload(method: str, job_payload: dict[str, Any]) -> di return base_id_or_error _, dry_run_error = strict_bool_argument(job_payload, "dry_run", method="metadata.cache.invalidate", default=False) return dry_run_error + if method == "metadata.module_owner_cache.prune": + return validate_metadata_module_owner_cache_prune_payload(job_payload) if method == "metadata.resolve_overrides": return validate_metadata_resolve_overrides_payload(job_payload) if method == "code.search": @@ -46457,10 +52979,10 @@ def adapter_start_job(payload: dict[str, Any]) -> dict[str, Any]: return invalid_argument("adapter.job.start", "method", "payload.method is required.") if method.startswith("adapter.job."): return adapter_public_error("adapter.job.start", "unsupported_method", {"message": "adapter job methods cannot be nested"}) - known_methods = {str(row.get("name") or "") for row in METHODS} + known_methods = set(adapter_method_registry()) if method not in known_methods: return invalid_argument("adapter.job.start", "method", "Unsupported adapter method.", allowed_values=sorted(known_methods)) - _, timeout_error = parse_int_argument(payload, "timeout_seconds", method="adapter.job.start", default=0, minimum=1) + requested_timeout, timeout_error = parse_int_argument(payload, "timeout_seconds", method="adapter.job.start", default=0, minimum=1) if timeout_error: return timeout_error preflight_error = validate_adapter_job_payload(method, job_payload) @@ -46472,7 +52994,7 @@ def adapter_start_job(payload: dict[str, Any]) -> dict[str, Any]: adapter_cleanup_jobs() job_id = uuid.uuid4().hex now = adapter_now() - timeout_seconds = adapter_job_timeout_seconds(job_payload, method=method) + timeout_seconds = float(requested_timeout) if requested_timeout else adapter_job_timeout_seconds(job_payload, method=method) with ADAPTER_JOB_LOCK: ADAPTER_JOBS[job_id] = { "schema": "onec_adapter_job.v1", @@ -46494,6 +53016,9 @@ def adapter_start_job(payload: dict[str, Any]) -> dict[str, Any]: heartbeat_thread.start() adapter_job_set(job_id, status="running", started_at=adapter_now(), progress={"current_step": "running", "completed_steps": 0, "total_steps": None, "percent": 0}) try: + if adapter_job_process_isolation_enabled(method): + adapter_run_job_in_process(job_id, method, job_payload, timeout_seconds) + return if method == "metadata.object.full": adapter_run_metadata_object_full_job(job_id, job_payload, timeout_seconds) return @@ -46559,7 +53084,8 @@ def adapter_start_job(payload: dict[str, Any]) -> dict[str, Any]: adapter_save_jobs_to_store() threading.Thread(target=worker, name=f"onec-adapter-job-{job_id[:8]}", daemon=True).start() - threading.Thread(target=watchdog, name=f"onec-adapter-timeout-{job_id[:8]}", daemon=True).start() + if not adapter_job_process_isolation_enabled(method): + threading.Thread(target=watchdog, name=f"onec-adapter-timeout-{job_id[:8]}", daemon=True).start() return { "schema": "onec_adapter_job.v1", "status": "accepted", @@ -46630,7 +53156,15 @@ def adapter_cancel_job(payload: dict[str, Any]) -> dict[str, Any]: job["status"] = "cancelled" job["finished_at"] = adapter_now() job["updated_at"] = job["finished_at"] - job["diagnostics"] = {"message": "Cancellation requested. A running section may finish in the background, but this adapter job will remain cancelled."} + isolated_process = adapter_job_process_isolation_enabled(str(job.get("method") or "")) + job["diagnostics"] = { + "message": ( + "Cancellation requested. The isolated worker process will be terminated." + if isolated_process + else "Cancellation requested. A running in-process section may finish in the background, but this adapter job will remain cancelled." + ), + "termination": "hard_process" if isolated_process else "cooperative", + } if job.get("method") == "metadata.object.attributes" and isinstance(job.get("partial_result"), dict): partial = dict(job.get("partial_result") or {}) progress = adapter_mark_attributes_cancelled(partial, started_at=float(job.get("started_at") or job.get("created_at") or adapter_now())) @@ -46871,17 +53405,271 @@ def metadata_object_attributes(payload: dict[str, Any]) -> dict[str, Any]: return result -def validate_repository_request_objects_sql(payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]: +REPOSITORY_FORM_SEGMENTS = {"form", "форма"} +REPOSITORY_FORM_OWNER_KINDS = {"Catalog", "Document", "DataProcessor", "Report"} + + +def repository_form_reference_parts(value: str) -> tuple[str, str, str] | None: + """Return (owner_kind, owner_name, form_name) for a public child-form ref.""" + parts = [part.strip() for part in value.strip().split(".")] + if len(parts) < 4 or parts[2].casefold() not in REPOSITORY_FORM_SEGMENTS: + return None + owner_kind = canonical_kind(parts[0]) or parts[0] + if owner_kind not in REPOSITORY_FORM_OWNER_KINDS or not parts[1] or not any(parts[3:]): + return None + return owner_kind, parts[1], ".".join(parts[3:]).strip() + + +def repository_resolve_owned_form_sql( + *, method: str, base_id: str, owner_kind: str, owner_name: str, form_name: str, + expected_form_guid: str | None = None, extension_guid: str | None = None, timeout_seconds: int = 60, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + attempts: list[dict[str, Any]] = [] + for table in ("Config", "ConfigSave", "ConfigCAS", "ConfigCASSave"): + owner_result = get_object(owner_kind, owner_name, base_id=base_id, view="effective", limit=20, timeout_seconds=timeout_seconds, table=table, extension_guid=extension_guid, include_semantic=False) + attempts.append({"stage": "owner", "table": table, "status": owner_result.get("status")}) + owner = owner_result.get("object") if owner_result.get("status") == "ok" and isinstance(owner_result.get("object"), dict) else None + if not owner: + continue + forms_payload = { + "base_id": base_id, + "kind": owner.get("kind") or owner_kind, + "name": owner.get("name") or owner_name, + "guid": owner.get("guid"), + "form": form_name, + "table": table, + "include_storage": True, + "timeout_seconds": timeout_seconds, + } + # `metadata.object.forms` deliberately distinguishes an absent optional + # extension selector from an explicitly invalid empty selector. + if extension_guid: + forms_payload["extension_guid"] = extension_guid + forms_result = metadata_object_forms(forms_payload) + attempts.append({"stage": "form", "table": table, "status": forms_result.get("status")}) + forms = forms_result.get("forms") if forms_result.get("status") == "ok" and isinstance(forms_result.get("forms"), list) else [] + form = next((item for item in forms if isinstance(item, dict) and (not expected_form_guid or str(item.get("guid") or "").casefold() == expected_form_guid.casefold())), None) + if not isinstance(form, dict): + continue + resolved_kind = str(owner.get("kind") or owner_kind).strip() + resolved_owner_name = str(owner.get("name") or owner_name).strip() + resolved_form_name = str(form.get("name") or form_name).strip() + origin = owner.get("origin") if isinstance(owner.get("origin"), dict) else {} + extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} + resolved_extension_guid = str(extension.get("guid") or extension_guid or "").lower() + cache_config, _ = sql_config_for_base(base_id) + metadata_form_owner_cache_upsert( + cache_config, base_id=base_id, owner_kind=resolved_kind, owner_name=resolved_owner_name, + owner_guid=str(owner.get("guid") or ""), form_name=resolved_form_name, + form_guid=str(form.get("guid") or ""), table=table, file_name=f"{str(form.get('guid') or '').lower()}.0", + extension={"guid": resolved_extension_guid} if is_guid_text(resolved_extension_guid) else None, + ) + return { + "requested_object": f"{owner_kind}.{owner_name}.Form.{form_name}", + "resolved_object": f"{resolved_kind}.{resolved_owner_name}.Form.{resolved_form_name}", + "form_guid": str(form.get("guid") or "").lower(), + "owner": {"kind": resolved_kind, "name": resolved_owner_name, "guid": owner.get("guid")}, + "table": table, "source": "live_sql_form_owner", "attempts": attempts, + **({"extension_guid": resolved_extension_guid} if is_guid_text(resolved_extension_guid) else {}), + }, None + return None, {"schema": "onec_repository_lock_request.v1", "method": method, "base_id": base_id, "status": "not_found", "error": "repository_form_not_found_in_sql", "object": f"{owner_kind}.{owner_name}.Form.{form_name}", "diagnostics": {"attempts": attempts}} + + +def repository_resolve_owned_form_across_layers_sql( + *, method: str, base_id: str, owner_kind: str, owner_name: str, form_name: str, + extension_guid: str | None = None, timeout_seconds: int = 60, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """Resolve a form in its SQL layer, discovering an extension only when needed.""" + resolved, base_error = repository_resolve_owned_form_sql( + method=method, base_id=base_id, owner_kind=owner_kind, owner_name=owner_name, + form_name=form_name, extension_guid=extension_guid, timeout_seconds=timeout_seconds, + ) + if resolved or extension_guid: + return resolved, base_error + + listed = list_extensions({"base_id": base_id, "timeout_seconds": timeout_seconds}) + extensions = listed.get("extensions") if listed.get("status") == "ok" and isinstance(listed.get("extensions"), list) else [] + matches: list[dict[str, Any]] = [] + extension_attempts: list[dict[str, Any]] = [] + for extension in extensions: + guid = str(extension.get("guid") or "").strip().lower() if isinstance(extension, dict) else "" + if not is_guid_text(guid): + continue + candidate, candidate_error = repository_resolve_owned_form_sql( + method=method, base_id=base_id, owner_kind=owner_kind, owner_name=owner_name, + form_name=form_name, extension_guid=guid, timeout_seconds=timeout_seconds, + ) + extension_attempts.append({"extension_guid": guid, "status": "ok" if candidate else str((candidate_error or {}).get("status") or "not_found")}) + if candidate: + matches.append(candidate) + if len(matches) == 1: + matches[0]["source"] = "live_sql_extension_form_owner" + return matches[0], None + if len(matches) > 1: + return None, { + "schema": "onec_repository_lock_request.v1", "method": method, "base_id": base_id, + "status": "ambiguous", "error": "ambiguous_repository_form_extension_origin", + "object": f"{owner_kind}.{owner_name}.Form.{form_name}", + "diagnostics": {"extension_matches": [item.get("extension_guid") for item in matches]}, + } + error = dict(base_error or {}) + diagnostics = dict(error.get("diagnostics") or {}) + diagnostics["extension_attempts"] = extension_attempts + if listed.get("status") != "ok": + diagnostics["extensions_list"] = listed + error["diagnostics"] = diagnostics + return None, error + + +def repository_resolve_form_guid_sql(*, method: str, base_id: str, form_guid: str, timeout_seconds: int = 60) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + config, _ = sql_config_for_base(base_id) + for table in ("Config", "ConfigSave", "ConfigCAS", "ConfigCASSave"): + cached = metadata_form_owner_cache_lookup(config, form_guid=form_guid) + owner = cached.get("owner") if isinstance((cached or {}).get("owner"), dict) else {} + form = cached.get("form") if isinstance((cached or {}).get("form"), dict) else {} + owner_kind = canonical_kind(str(owner.get("kind") or "")) or str(owner.get("kind") or "") + owner_name, form_name = str(owner.get("name") or "").strip(), str(form.get("name") or "").strip() + if owner_kind in REPOSITORY_FORM_OWNER_KINDS and owner_name and form_name: + extension = cached.get("extension") if isinstance(cached.get("extension"), dict) else {} + resolved, error = repository_resolve_owned_form_sql(method=method, base_id=base_id, owner_kind=owner_kind, owner_name=owner_name, form_name=form_name, expected_form_guid=form_guid, extension_guid=str(extension.get("guid") or "") or None, timeout_seconds=timeout_seconds) + if resolved: + resolved.update({"requested_object": f"Form.{form_guid}", "source": "live_sql_form_guid_validated"}) + return resolved, error + # A cache miss is not a negative result. Build the owner relationship from + # live SQL descriptors, keeping the scan bounded to form-capable owners. + for owner_kind in sorted(REPOSITORY_FORM_OWNER_KINDS): + owners_result = list_objects( + owner_kind, base_id=base_id, limit=5000, include_storage=True, + refresh_cache=True, table="Config", + ) + owners = owners_result.get("objects") if owners_result.get("status") == "ok" and isinstance(owners_result.get("objects"), list) else [] + for owner in owners: + if not isinstance(owner, dict) or not str(owner.get("guid") or "").strip(): + continue + related = metadata_object_related({ + "base_id": base_id, "kind": owner_kind, "guid": owner.get("guid"), + "table": "Config", "include_storage": False, "timeout_seconds": timeout_seconds, + }) + related_items = related.get("related") if related.get("status") == "ok" and isinstance(related.get("related"), list) else [] + match = next( + ( + item for item in related_items + if isinstance(item, dict) + and str(item.get("category") or "").casefold() == "form" + and str(((item.get("identity") or {}).get("guid") if isinstance(item.get("identity"), dict) else item.get("guid")) or "").casefold() == form_guid.casefold() + ), + None, + ) + identity = match.get("identity") if isinstance((match or {}).get("identity"), dict) else {} + form_name = str(identity.get("name") or "").strip() + owner_name = str(owner.get("name") or "").strip() + if not form_name or not owner_name: + continue + resolved, error = repository_resolve_owned_form_sql( + method=method, base_id=base_id, owner_kind=owner_kind, owner_name=owner_name, + form_name=form_name, expected_form_guid=form_guid, timeout_seconds=timeout_seconds, + ) + if resolved: + resolved.update({"requested_object": f"Form.{form_guid}", "source": "live_sql_form_guid_scan"}) + return resolved, error + return None, {"schema": "onec_repository_lock_request.v1", "method": method, "base_id": base_id, "status": "not_found", "error": "repository_form_owner_not_found_in_sql", "object": f"Form.{form_guid}", "diagnostics": {"message": "Pass OwnerKind.OwnerName.Form.FormName or build a live SQL form-owner index before using Form.."}} + + +_FORM_MODULE_FILE_RE = re.compile(r"^(?:(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})__)?(?P
[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.0$", re.IGNORECASE) + + +def resolve_write_gate_context(payload: dict[str, Any]) -> dict[str, Any]: + """Derive exact form ownership for a physical module reference before gates. + + This is read-only and deliberately does *not* select a lock session. The + caller must still present the lock_session_id returned by confirmation. + """ + result = dict(payload) + existing = payload.get("owner_resolution") if isinstance(payload.get("owner_resolution"), dict) else {} + if existing.get("status") == "resolved" and str(existing.get("repository_object") or "").strip(): + return result + target = dict(payload.get("target") or {}) if isinstance(payload.get("target"), dict) else {} + module_ref = str( + target.get("module_ref") or target.get("module_id") or payload.get("module_ref") or payload.get("module_id") or "" + ).strip() + table, file_name, _stream = parse_module_id(module_ref) + match = _FORM_MODULE_FILE_RE.fullmatch(file_name or "") if table and file_name else None + if not match: + return result + base_id = str(payload.get("base_id") or "").strip() + if not base_id: + return result + form_guid = str(match.group("form") or "").lower() + resolved, error = repository_resolve_form_guid_sql( + method="write.gate_context", base_id=base_id, form_guid=form_guid, + timeout_seconds=int(payload.get("timeout_seconds") or 60), + ) + if not resolved: + result["owner_resolution"] = { + "status": "not_resolved", "module_ref": module_ref, "form_guid": form_guid, + "diagnostics": error, + } + return result + canonical_object = str(resolved.get("resolved_object") or "").strip() + resolved_extension_guid = str(resolved.get("extension_guid") or "").strip().lower() + # For a physical form module, SQL ownership is authoritative. Do not let + # a caller choose a different repository scope or configuration layer. + result["repository_object"] = canonical_object + result["support_object_guid"] = form_guid + if resolved_extension_guid: + result["extension_guid"] = resolved_extension_guid + origin = target.get("origin") if isinstance(target.get("origin"), dict) else {} + target["origin"] = {**origin, "extension": {"guid": resolved_extension_guid}} + else: + result.pop("extension_guid", None) + if isinstance(target.get("origin"), dict): + origin = dict(target["origin"]) + origin.pop("extension", None) + target["origin"] = origin + if target: + result["target"] = target + result["owner_resolution"] = { + "status": "resolved", "source": "live_sql_form_guid", + "module_ref": module_ref, "form_guid": form_guid, + "repository_object": canonical_object, + "owner": resolved.get("owner"), "layer_id": f"extension:{resolved_extension_guid}" if resolved_extension_guid else "base", + } + return result + + +def validate_repository_request_objects_sql(payload: dict[str, Any], *, method: str = repository_control.METHOD_LOCK_REQUEST) -> tuple[dict[str, Any], dict[str, Any] | None]: objects, object_error = repository_control._requested_objects(payload) if object_error: - return payload, {"schema": "onec_repository_lock_request.v1", "method": repository_control.METHOD_LOCK_REQUEST, **object_error} + return payload, {"schema": "onec_repository_lock_request.v1", "method": method, **object_error} base_id = str(payload.get("base_id") or "").strip() canonical_objects: list[str] = [] resolutions: list[dict[str, Any]] = [] for public_ref in objects or []: + form_parts = repository_form_reference_parts(public_ref) + if form_parts: + resolved_form, form_error = repository_resolve_owned_form_across_layers_sql( + method=method, base_id=base_id, owner_kind=form_parts[0], owner_name=form_parts[1], form_name=form_parts[2], + extension_guid=str(payload.get("extension_guid") or "").strip().lower() or None, + timeout_seconds=int(payload.get("timeout_seconds") or 60), + ) + if form_error: + return payload, form_error + canonical_objects.append(str(resolved_form["resolved_object"])) + resolutions.append(resolved_form) + continue + parts = public_ref.strip().split(".") + if len(parts) == 2 and parts[0].casefold() in REPOSITORY_FORM_SEGMENTS and is_guid_text(parts[1].strip()): + resolved_form, form_error = repository_resolve_form_guid_sql( + method=method, base_id=base_id, form_guid=parts[1].strip().lower(), timeout_seconds=int(payload.get("timeout_seconds") or 60), + ) + if form_error: + return payload, form_error + canonical_objects.append(str(resolved_form["resolved_object"])) + resolutions.append(resolved_form) + continue kind_text, separator, name = public_ref.partition(".") if not separator or not kind_text.strip() or not name.strip(): - return payload, invalid_argument(repository_control.METHOD_LOCK_REQUEST, "objects", f"Repository object '{public_ref}' must use a public Kind.Name reference.") + return payload, invalid_argument(method, "objects", f"Repository object '{public_ref}' must use a public Kind.Name reference.") result: dict[str, Any] = {} resolved_table = "" attempts: list[dict[str, Any]] = [] @@ -46897,7 +53685,7 @@ def validate_repository_request_objects_sql(payload: dict[str, Any]) -> tuple[di if result.get("status") != "ok" or not isinstance(result.get("object"), dict): return payload, { "schema": "onec_repository_lock_request.v1", - "method": repository_control.METHOD_LOCK_REQUEST, + "method": method, "base_id": base_id, "status": "not_found", "error": "repository_object_not_found_in_sql", @@ -46912,6 +53700,15 @@ def validate_repository_request_objects_sql(payload: dict[str, Any]) -> tuple[di normalized = dict(payload) normalized["objects"] = canonical_objects normalized["sql_resolution"] = resolutions + resolved_extensions = {str(item.get("extension_guid") or "").lower() for item in resolutions if isinstance(item, dict) and is_guid_text(str(item.get("extension_guid") or ""))} + if len(resolved_extensions) > 1: + return payload, invalid_argument(method, "objects", "One lock request cannot mix objects from different extension layers.") + if resolved_extensions: + resolved_extension = next(iter(resolved_extensions)) + requested_extension = str(payload.get("extension_guid") or "").strip().lower() + if requested_extension and requested_extension != resolved_extension: + return payload, invalid_argument(method, "extension_guid", "extension_guid does not match the resolved object origin.") + normalized["extension_guid"] = resolved_extension return normalized, None @@ -46930,7 +53727,7 @@ def call_method_impl(method: str, payload: dict[str, Any] | None) -> dict[str, A selector_argument_error = validate_object_selector_arguments(payload, method, include_view=not template_view_method) if selector_argument_error: return selector_argument_error - normalized_payload = normalize_object_selector_aliases(payload, method) + normalized_payload = normalize_selector_payload_for_method(payload, method) if isinstance(normalized_payload, dict) and normalized_payload.get("status") == "invalid_argument": return normalized_payload payload = normalized_payload @@ -46945,14 +53742,31 @@ def call_method_impl(method: str, payload: dict[str, Any] | None) -> dict[str, A if validation_error: return validation_error selected = payload.get("method") - methods = [public_method_row(row) for row in METHODS if not selected or row["name"] == selected] - return {"schema": "onec_adapter_methods.v1", "contract_version": ADAPTER_CONTRACT_VERSION, "methods": methods, "count": len(methods)} + registry = adapter_method_registry() + methods = [row for name, row in registry.items() if not selected or name == selected] + return { + "schema": "onec_adapter_methods.v1", + "contract_version": ADAPTER_CONTRACT_VERSION, + "methods": methods, + "count": len(methods), + "registry": adapter_method_registry_diagnostics(), + } + if method == "repository.layer.connection.set": + validation_error = validate_adapter_job_payload(method, payload) + if validation_error: + return validation_error + return repository_layer_connection_set(payload) + if method == "repository.layers.audit": + validation_error = validate_adapter_job_payload(method, payload) + if validation_error: + return validation_error + return repository_layers_audit(payload) if method in repository_control.METHODS: validation_error = validate_adapter_job_payload(method, payload) if validation_error: return validation_error - if method == repository_control.METHOD_LOCK_REQUEST: - payload, repository_object_error = validate_repository_request_objects_sql(payload) + if method in {repository_control.METHOD_LOCK_PLAN, repository_control.METHOD_LOCK_REQUEST}: + payload, repository_object_error = validate_repository_request_objects_sql(payload, method=method) if repository_object_error: return repository_object_error return repository_control.call(method, payload) @@ -47166,6 +53980,16 @@ def call_method_impl(method: str, payload: dict[str, Any] | None) -> dict[str, A return metadata_form_element_write(payload) if method == "metadata.form.element.write_apply": return metadata_form_element_write_apply(payload) + if method == OBJECT_PROPERTY_WRITE_METHOD: + validation_error = validate_metadata_object_property_write_payload(payload) + if validation_error: + return validation_error + return metadata_object_property_write(payload) + if method == OBJECT_MEMBER_ADD_METHOD: + validation_error = validate_metadata_object_member_add_payload(payload) + if validation_error: + return validation_error + return metadata_object_member_add(payload) if method == FORM_TARGET_MOVE_METHOD: return metadata_form_target_move(payload) if method == FORM_COMMAND_BUTTON_WRITE_METHOD: @@ -47352,6 +54176,12 @@ def call_method_impl(method: str, payload: dict[str, Any] | None) -> dict[str, A return storage_saved_state_backups_list(payload) if method == "metadata.dbnames.summary": return metadata_dbnames_summary(payload) + if method == "metadata.support.decode": + return metadata_support_decode(payload) + if method == "repository.sql_state.snapshot": + return repository_sql_state_snapshot(payload) + if method == "repository.sql_state.diff": + return repository_sql_state_diff(payload) if method == "payload.diff": validation_error = validate_payload_diff_payload(payload) if validation_error: @@ -47554,6 +54384,7 @@ def sql_admin_public(config: dict[str, Any]) -> list[dict[str, Any]]: "password_env": password_env, "has_password": bool(item.get("password") or (password_env and os.environ.get(password_env))), "repository": repository_control._public_config(item["repository"]) if isinstance(item.get("repository"), dict) else None, + "development_layers": repository_control.public_development_layers(base_id), }) return result @@ -47570,6 +54401,76 @@ def sql_admin_save(config: dict[str, Any]) -> None: temp.replace(path) +def repository_layer_connection_set(payload: dict[str, Any]) -> dict[str, Any]: + """Persist one layer's repository connection fact in adapter-owned config.""" + method = "repository.layer.connection.set" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + if payload.get("confirm_repository_connection_change") is not True: + return invalid_argument( + method, + "confirm_repository_connection_change", + "This changes adapter configuration only. Pass confirm_repository_connection_change=true after confirming the layer connection state.", + ) + base_id = base_id_or_error + extension_guid = str(payload.get("extension_guid") or "").strip().lower() + requested_layer = str(payload.get("layer_id") or "").strip().lower() + if extension_guid: + if not is_guid_text(extension_guid): + return invalid_argument(method, "extension_guid", "extension_guid must be a GUID string.") + layer_id = f"extension:{extension_guid}" + else: + layer_id = requested_layer or "base" + if layer_id != "base" and not re.fullmatch(r"extension:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", layer_id): + return invalid_argument(method, "layer_id", "Pass base or extension:.") + connection_state = str(payload.get("connection_state") or "").strip().casefold() + if connection_state not in repository_control.SUPPORTED_REPOSITORY_CONNECTION_STATES: + return invalid_argument(method, "connection_state", "connection_state must be not_configured, configured, unavailable, or unknown.") + repository_user = payload.get("repository_user") + if repository_user is not None and (not isinstance(repository_user, str) or not repository_user.strip()): + return invalid_argument(method, "repository_user", "repository_user must be a non-empty string when provided.") + try: + config = sql_admin_load() + except ValueError as exc: + return {"schema": "onec_repository_layer_connection_set.v1", "method": method, "base_id": base_id, "status": "blocked", "error": "adapter_configuration_not_writable", "message": str(exc)} + item = config.get(base_id) + if not isinstance(item, dict): + return {"schema": "onec_repository_layer_connection_set.v1", "method": method, "base_id": base_id, "status": "not_found", "error": "base_not_configured"} + layers = item.get("development_layers") if isinstance(item.get("development_layers"), dict) else {} + if not layers and layer_id == "base": + legacy_repository = item.get("repository") if isinstance(item.get("repository"), dict) else {} + layers = {"base": {"repository": dict(legacy_repository), "support": {"mode": "unknown"}}} + layer = layers.get(layer_id) if isinstance(layers.get(layer_id), dict) else {} + repository = dict(layer.get("repository") or {}) if isinstance(layer.get("repository"), dict) else {} + current_mode = str(repository.get("mode") or repository.get("lock_mode") or "unknown").strip().casefold() + if connection_state == "not_configured": + repository["mode"] = "none" + repository["lock_mode"] = "manual" + elif current_mode in {"none", "unknown"}: + repository["mode"] = "manual" + repository["lock_mode"] = "manual" + repository["connection_state"] = connection_state + if repository_user is not None: + repository["repository_user"] = repository_user.strip() + layers[layer_id] = {"repository": repository, "support": dict(layer.get("support") or {"mode": "unknown"})} + item["development_layers"] = layers + config[base_id] = item + sql_admin_save(config) + status = repository_control.status({"base_id": base_id, "layer_id": layer_id}) + return { + "schema": "onec_repository_layer_connection_set.v1", + "method": method, + "base_id": base_id, + "layer_id": layer_id, + "status": "saved", + "connection_state": connection_state, + "repository": status.get("repository"), + "repository_status": status.get("status"), + "message": "Saved in adapter configuration only; no 1C SQL database was changed.", + } + + def sql_admin_validate(payload: dict[str, Any]) -> tuple[str, dict[str, Any]]: base_id = str(payload.get("base_id") or "").strip() if not re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", base_id): @@ -47585,6 +54486,35 @@ def sql_admin_validate(payload: dict[str, Any]) -> tuple[str, dict[str, Any]]: elif password: item["password"] = password repository = payload.get("repository") + development_layers = payload.get("development_layers") + if development_layers is not None: + if not isinstance(development_layers, dict): + raise ValueError("development_layers должен быть JSON-объектом с ключами base и extension:.") + normalized_layers: dict[str, Any] = {} + for raw_layer_id, raw_layer in development_layers.items(): + layer_id = str(raw_layer_id or "").strip().lower() + if layer_id != "base" and not re.fullmatch(r"extension:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", layer_id): + raise ValueError("development_layers: ключ должен быть base или extension:.") + if not isinstance(raw_layer, dict): + raise ValueError(f"development_layers.{layer_id} должен быть JSON-объектом.") + raw_repository = raw_layer.get("repository") if isinstance(raw_layer.get("repository"), dict) else {"mode": "none"} + repository_mode = str(raw_repository.get("mode") or raw_repository.get("lock_mode") or "none").strip().casefold() + if repository_mode not in repository_control.SUPPORTED_REPOSITORY_MODES: + raise ValueError(f"development_layers.{layer_id}.repository.mode: допустимы none, manual, automatic и unknown.") + default_connection_state = "unknown" if repository_mode == "unknown" else ("not_configured" if repository_mode == "none" else "configured") + connection_state = str(raw_repository.get("connection_state") or raw_repository.get("repository_connection") or raw_repository.get("connection") or default_connection_state).strip().casefold() + if connection_state not in repository_control.SUPPORTED_REPOSITORY_CONNECTION_STATES: + raise ValueError(f"development_layers.{layer_id}.repository.connection_state: допустимы not_configured, configured, unavailable и unknown.") + raw_support = raw_layer.get("support") if isinstance(raw_layer.get("support"), dict) else {"mode": "unknown"} + support_mode = str(raw_support.get("mode") or "unknown").strip().casefold() + if support_mode not in repository_control.SUPPORTED_SUPPORT_MODES: + raise ValueError(f"development_layers.{layer_id}.support.mode: допустимы none, editable, locked, rules и unknown.") + rules = raw_support.get("rules") if isinstance(raw_support.get("rules"), dict) else {} + normalized_layers[layer_id] = { + "repository": {**raw_repository, "mode": repository_mode, "lock_mode": repository_mode if repository_mode != "none" else "manual", "connection_state": connection_state}, + "support": {"mode": support_mode, **({"rules": {str(key): str(value) for key, value in rules.items()}} if rules else {})}, + } + item["development_layers"] = normalized_layers if repository is not None: if not isinstance(repository, dict): raise ValueError("repository должен быть JSON-объектом.") @@ -47709,20 +54639,8 @@ class Handler(BaseHTTPRequestHandler): self.write_json(200, {"bases": sql_admin_public(sql_admin_load()), "config_path": str(sql_admin_config_path())}) elif parsed.path == "/admin/api/repository/requests": self.write_json(200, repository_control.admin_state(str(params.get("base_id") or ""))) - elif parsed.path == "/health": - self.write_json(200, STATE.health(base_id=params.get("base_id"))) - elif parsed.path == "/methods": - self.write_json(200, call_method("help.methods", params)) - elif parsed.path == "/metadata/kinds": - self.write_json(200, call_method("metadata.kinds", params)) - elif parsed.path == "/metadata/objects": - self.write_json(200, call_method("metadata.objects.list", params)) - elif parsed.path == "/metadata/object": - self.write_json(200, call_method("metadata.object.get", params)) - elif parsed.path == "/extensions": - self.write_json(200, call_method("extensions.list", params)) - elif parsed.path == "/modules/read": - self.write_json(200, call_method("modules.read", params)) + elif parsed.path in HTTP_GET_METHOD_ROUTES: + self.write_json(200, call_method(HTTP_GET_METHOD_ROUTES[parsed.path], params)) else: self.write_json(404, {"error": "not_found", "path": parsed.path}) except ValueError as exc: @@ -47751,116 +54669,8 @@ class Handler(BaseHTTPRequestHandler): self.write_json(201, {"base": sql_admin_public({base_id: item})[0]}) elif parsed.path == "/rpc": self.write_json(200, call_method(str(payload.get("method") or ""), payload.get("payload") or {})) - elif parsed.path == "/metadata/snapshot": - self.write_json(200, call_method("metadata.snapshot", payload)) - elif parsed.path == "/metadata/object/decode": - self.write_json(200, call_method("metadata.object.decode", payload)) - elif parsed.path == "/metadata/object/parts": - self.write_json(200, call_method("metadata.object.parts", payload)) - elif parsed.path == "/metadata/object/modules": - self.write_json(200, call_method("metadata.object.modules", payload)) - elif parsed.path == "/metadata/object/related": - self.write_json(200, call_method("metadata.object.related", payload)) - elif parsed.path == "/metadata/object/forms": - self.write_json(200, call_method("metadata.object.forms", payload)) - elif parsed.path == "/metadata/object/form-details": - self.write_json(200, call_method("metadata.object.form.details", payload)) - elif parsed.path == "/metadata/object/templates": - self.write_json(200, call_method("metadata.object.templates", payload)) - elif parsed.path == "/metadata/object/template-details": - self.write_json(200, call_method("metadata.object.template.details", payload)) - elif parsed.path == "/templates/read": - self.write_json(200, call_method("templates.read", payload)) - elif parsed.path == "/templates/analyze": - self.write_json(200, call_method("templates.analyze", payload)) - elif parsed.path == "/templates/map": - self.write_json(200, call_method("templates.map", payload)) - elif parsed.path == "/metadata/object/commands": - self.write_json(200, call_method("metadata.object.commands", payload)) - elif parsed.path == "/metadata/route/resolve": - self.write_json(200, call_method("metadata.route.resolve", payload)) - elif parsed.path == "/metadata/object/special-details": - self.write_json(200, call_method("metadata.object.special.details", payload)) - elif parsed.path == "/metadata/form/decode": - self.write_json(200, call_method("metadata.form.decode", payload)) - elif parsed.path == "/metadata/form/write-target/resolve": - self.write_json(200, call_method("metadata.form.write_target.resolve", payload)) - elif parsed.path == "/metadata/saved-state/forms/search": - self.write_json(200, call_method("metadata.saved_state.forms.search", payload)) - elif parsed.path == "/metadata/form/element-write": - self.write_json(200, call_method("metadata.form.element.write", payload)) - elif parsed.path == "/metadata/form/element-write-apply": - self.write_json(200, call_method("metadata.form.element.write_apply", payload)) - elif parsed.path == "/metadata/write-plan": - self.write_json(200, call_method("metadata.write.plan", payload)) - elif parsed.path == "/metadata/write-capabilities": - self.write_json(200, call_method("metadata.write.capabilities", payload)) - elif parsed.path == "/code/write": - self.write_json(200, call_method("code.write", payload)) - elif parsed.path == "/metadata/object/attributes": - self.write_json(200, call_method("metadata.object.attributes", payload)) - elif parsed.path == "/metadata/object/full": - self.write_json(200, call_method("metadata.object.full", payload)) - elif parsed.path == "/metadata/resolve-overrides": - self.write_json(200, call_method("metadata.resolve_overrides", payload)) - elif parsed.path == "/modules/search": - self.write_json(200, call_method("modules.search", payload)) - elif parsed.path == "/code/search": - self.write_json(200, call_method("code.search", payload)) - elif parsed.path == "/code/read": - self.write_json(200, call_method("code.read", payload)) - elif parsed.path == "/code/symbol/resolve": - self.write_json(200, call_method("code.symbol.resolve", payload)) - elif parsed.path == "/templates/bindings": - self.write_json(200, call_method("templates.bindings", payload)) - elif parsed.path == "/diagnostics/call-chain": - self.write_json(200, call_method("diagnostics.call_chain", payload)) - elif parsed.path == "/metadata/module-owner-cache/prune": - self.write_json(200, call_method("metadata.module_owner_cache.prune", payload)) - elif parsed.path == "/access/snapshot/extract": - self.write_json(200, call_method("access.snapshot.extract", payload)) - elif parsed.path == "/access/graph": - self.write_json(200, call_method("access.graph.build", payload)) - elif parsed.path == "/access/user/explain": - self.write_json(200, call_method("access.user.explain", payload)) - elif parsed.path == "/access/users/search": - self.write_json(200, call_method("access.users.search", payload)) - elif parsed.path == "/access/keys/query": - self.write_json(200, call_method("access.keys.query", payload)) - elif parsed.path == "/access/object-keys/resolve": - self.write_json(200, call_method("access.object_keys.resolve", payload)) - elif parsed.path == "/access/object/explain": - self.write_json(200, call_method("access.object.explain", payload)) - elif parsed.path == "/access/object/roles": - self.write_json(200, call_method("access.object.roles", payload)) - elif parsed.path == "/access/object/subjects": - self.write_json(200, call_method("access.object.subjects", payload)) - elif parsed.path == "/access/rls/discover": - self.write_json(200, call_method("access.rls.discover", payload)) - elif parsed.path == "/access/role/profiles": - self.write_json(200, call_method("access.role.profiles", payload)) - elif parsed.path == "/access/role/users": - self.write_json(200, call_method("access.role.users", payload)) - elif parsed.path == "/access/role/audit-export": - self.write_json(200, call_method("access.role.audit_export", payload)) - elif parsed.path == "/access/role/audit-analyze": - self.write_json(200, call_method("access.role.audit_analyze", payload)) - elif parsed.path == "/extension/objects/find": - self.write_json(200, call_method("extension.objects.find", payload)) - elif parsed.path == "/query/validate": - self.write_json(200, call_method("query.validate", payload)) - elif parsed.path == "/query/run": - self.write_json(200, call_method("query.run", payload)) - elif parsed.path == "/codec/decode": - self.write_json(200, call_method("codec.decode", payload)) - elif parsed.path == "/codec/encode": - self.write_json(200, call_method("codec.encode", payload)) - elif parsed.path == "/changes/propose": - self.write_json(200, call_method("changes.propose", payload)) - elif parsed.path == "/storage/saved-state/apply-proposal": - self.write_json(200, call_method("storage.saved_state.apply_proposal", payload)) - elif parsed.path == "/storage/saved-state/rollback": - self.write_json(200, call_method("storage.saved_state.rollback", payload)) + elif parsed.path in HTTP_POST_METHOD_ROUTES: + self.write_json(200, call_method(HTTP_POST_METHOD_ROUTES[parsed.path], payload)) else: self.write_json(404, {"error": "not_found", "path": parsed.path}) except ValueError as exc: diff --git a/plugins/1c/connector/admin/app.js b/plugins/1c/connector/admin/app.js index 7f20d8a..b45018d 100644 --- a/plugins/1c/connector/admin/app.js +++ b/plugins/1c/connector/admin/app.js @@ -74,12 +74,7 @@ function openEditor(base = null) { $("baseId").value = base?.base_id || ""; $("server").value = base?.server || ""; $("database").value = base?.database || ""; $("user").value = base?.user || ""; $("password").value = ""; $("passwordEnv").value = base?.password_env || ""; - $("repositoryEnabled").checked = Boolean(base?.repository); - $("repositoryBackend").value = base?.repository?.backend || "direct"; - $("repositoryLayer").value = base?.repository?.layer || "base"; - $("repositoryLockMode").value = base?.repository?.lock_mode || "automatic"; - $("repositoryBridgeId").value = base?.repository?.bridge_id || ""; - $("repositoryUser").value = base?.repository?.repository_user || ""; + $("developmentLayers").value = JSON.stringify(base?.development_layers || { base: { repository: { mode: "none", connection_state: "not_configured" }, support: { mode: "unknown" } } }, null, 2); $("passwordHint").textContent = base?.has_password ? "Пароль уже задан. Оставьте пустым, чтобы сохранить текущий." : "Задайте пароль или переменную окружения."; $("editor").showModal(); setTimeout(() => $("baseId").focus(), 30); } @@ -88,7 +83,10 @@ async function save(event) { event.preventDefault(); if (!$("baseForm").reportValidity()) return; const original = $("originalId").value; - const payload = { base_id: $("baseId").value.trim(), server: $("server").value.trim(), database: $("database").value.trim(), user: $("user").value.trim(), password: $("password").value, password_env: $("passwordEnv").value.trim(), repository: $("repositoryEnabled").checked ? { backend: $("repositoryBackend").value, layer: $("repositoryLayer").value, lock_mode: $("repositoryLockMode").value, bridge_id: $("repositoryBridgeId").value.trim(), repository_user: $("repositoryUser").value.trim() } : { enabled: false } }; + let developmentLayers; + try { developmentLayers = JSON.parse($("developmentLayers").value || "{}"); } + catch (_) { notice("Слои разработки должны быть корректным JSON.", true); return; } + const payload = { base_id: $("baseId").value.trim(), server: $("server").value.trim(), database: $("database").value.trim(), user: $("user").value.trim(), password: $("password").value, password_env: $("passwordEnv").value.trim(), development_layers: developmentLayers, repository: { enabled: false } }; $("saveButton").disabled = true; try { await request(original ? `/admin/api/bases/${encodeURIComponent(original)}` : "/admin/api/bases", { method: original ? "PUT" : "POST", body: JSON.stringify(payload) }); diff --git a/plugins/1c/connector/admin/index.html b/plugins/1c/connector/admin/index.html index a149e7f..bc04fb9 100644 --- a/plugins/1c/connector/admin/index.html +++ b/plugins/1c/connector/admin/index.html @@ -43,12 +43,7 @@ - - - - - - +
diff --git a/plugins/1c/connector/contracts/openapi.yaml b/plugins/1c/connector/contracts/openapi.yaml index fdbacdd..544d3d1 100644 --- a/plugins/1c/connector/contracts/openapi.yaml +++ b/plugins/1c/connector/contracts/openapi.yaml @@ -16,6 +16,164 @@ paths: responses: "200": description: Connector health. + /methods: + get: + operationId: listAdapterMethods + summary: Runtime adapter method registry + description: Returns every supported RPC method, transport, description, selector capabilities, and input schema known by the running adapter. + parameters: + - name: method + in: query + required: false + schema: + type: string + description: Optional exact method name. + responses: + "200": + description: Runtime registry generated from the adapter method definitions. + content: + application/json: + schema: + $ref: "#/components/schemas/AdapterMethodsResponse" + /rpc: + post: + operationId: callAdapterMethod + summary: Universal adapter RPC + description: Calls any method advertised by GET /methods. The selected method's input_schema from /methods defines payload. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdapterRpcRequest" + responses: + "200": + description: Method-specific adapter response. Application errors are returned as structured JSON statuses. + content: + application/json: + schema: + type: object + additionalProperties: true + /extensions: + get: + operationId: listExtensions + parameters: + - $ref: "#/components/parameters/BaseId" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /metadata/object/templates: + post: + operationId: listObjectTemplates + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /metadata/object/template-details: + post: + operationId: getObjectTemplateDetails + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /metadata/object/form-details: + post: + operationId: getObjectFormDetails + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /metadata/object/commands: + post: + operationId: listObjectCommands + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /metadata/object/special-details: + post: + operationId: getObjectSpecialDetails + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /metadata/module-owner-cache/prune: + post: + operationId: pruneModuleOwnerCache + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /code/search: + post: + operationId: searchCode + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /code/read: + post: + operationId: readCode + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /templates/bindings: + post: + operationId: getTemplateBindings + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /diagnostics/call-chain: + post: + operationId: diagnoseCallChain + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /access/users/search: + post: + operationId: searchAccessUsers + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /access/object/roles: + post: + operationId: getAccessObjectRoles + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /access/object/subjects: + post: + operationId: getAccessObjectSubjects + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" + /access/rls/discover: + post: + operationId: discoverAccessRls + requestBody: + $ref: "#/components/requestBodies/AdapterMethodPayload" + responses: + "200": + $ref: "#/components/responses/AdapterMethodResponse" /metadata/kinds: get: operationId: getMetadataKinds @@ -1734,7 +1892,11 @@ paths: type: string enum: [group, user_set, object, set, all] default: all - description: BSP access key area to page through. + description: BSP access key area to page through; this is not a 1C metadata kind. + area: + type: string + enum: [group, user_set, object, set, all] + description: Alias of kind. group: type: string description: Filter group access keys by normalized group id. @@ -1744,9 +1906,21 @@ paths: user_set: type: string description: Filter user-set access keys by normalized user set id. + object_ref: + type: string + description: Public 1C metadata object ref, for example Справочники.Номенклатура. + record_ref: + type: string + description: Concrete 1C application-data record reference. object: type: string - description: Filter object access keys by normalized object id. + description: Legacy raw BSP object filter; prefer object_ref plus record_ref. + object_id: + type: string + description: Legacy raw BSP record id; prefer record_ref. + object_sql_number: + type: integer + description: Legacy physical DBNames number; prefer object_ref. access_set: type: string description: Filter access-set keys by normalized access set id. @@ -1795,9 +1969,21 @@ paths: properties: base_id: type: string + object_ref: + type: string + description: Public 1C metadata object ref, for example Справочники.Номенклатура. + record_ref: + type: string + description: Concrete 1C application-data record reference. object: type: string - description: Optional normalized object ref filter. + description: Legacy raw BSP object filter; prefer object_ref plus record_ref. + object_id: + type: string + description: Legacy raw BSP record id; prefer record_ref. + object_sql_number: + type: integer + description: Legacy physical DBNames number; prefer object_ref. access_key: type: string description: Optional access key filter. @@ -1836,15 +2022,21 @@ paths: properties: base_id: type: string + object_ref: + type: string + description: Public 1C metadata object ref, for example Справочники.Номенклатура. + record_ref: + type: string + description: Concrete 1C application-data record reference. object: type: string - description: Normalized object ref from access_object_keys.object. + description: Legacy raw BSP object filter; prefer object_ref plus record_ref. object_id: type: string - description: Data record id from access_object_keys.object_id. + description: Legacy raw BSP record id; prefer record_ref. object_sql_number: type: integer - description: Optional SQL metadata number to narrow object key rows. + description: Legacy physical DBNames number; prefer object_ref. access_key: type: string description: Access key id to explain directly. @@ -2020,6 +2212,27 @@ paths: "200": description: Risk findings for role -> profile -> access group -> user audit chains. components: + requestBodies: + AdapterMethodPayload: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + examples: + selectorByName: + value: + base_id: + ref: Документы.<ИмяОбъекта> + responses: + AdapterMethodResponse: + description: Method-specific adapter response with a structured status. + content: + application/json: + schema: + type: object + additionalProperties: true parameters: BaseId: name: base_id @@ -2033,6 +2246,53 @@ components: type: http scheme: bearer schemas: + AdapterRpcRequest: + type: object + required: [method] + additionalProperties: false + properties: + method: + type: string + minLength: 1 + description: Exact method name advertised by GET /methods. + payload: + type: object + default: {} + additionalProperties: true + description: Method-specific input. Use the matching input_schema returned by GET /methods. + AdapterMethod: + type: object + required: [name, transport, description] + additionalProperties: true + properties: + name: + type: string + transport: + type: string + description: + type: string + selector_capabilities: + type: object + additionalProperties: true + input_schema: + type: object + additionalProperties: true + AdapterMethodsResponse: + type: object + required: [schema, contract_version, methods, count] + properties: + schema: + type: string + const: onec_adapter_methods.v1 + contract_version: + type: string + methods: + type: array + items: + $ref: "#/components/schemas/AdapterMethod" + count: + type: integer + minimum: 0 MetadataKind: type: string enum: diff --git a/plugins/1c/connector/repository_control.py b/plugins/1c/connector/repository_control.py index 93448ce..401a727 100644 --- a/plugins/1c/connector/repository_control.py +++ b/plugins/1c/connector/repository_control.py @@ -15,6 +15,14 @@ from typing import Any METHOD_STATUS = "repository.status" +SQL_LOCK_EVIDENCE = { + "native_lock_state": "unknown", + "native_lock_verification": "not_available_via_infobase_sql", + "excluded_sources": { + "_ConfigChngR": "exchange_plan_change_registration", + "_ConfigChngR_ExtProps": "exchange_plan_changed_file_details", + }, +} METHOD_LOCK_PLAN = "repository.lock.plan" METHOD_LOCK_REQUEST = "repository.lock.request" METHOD_LOCK_REQUEST_STATUS = "repository.lock.request.status" @@ -29,6 +37,9 @@ METHOD_COMMIT = "repository.commit" METHODS = {METHOD_STATUS, METHOD_LOCK_PLAN, METHOD_LOCK_REQUEST, METHOD_LOCK_REQUEST_STATUS, METHOD_LOCK_REQUEST_CANCEL, METHOD_LOCK, METHOD_CONFIRM, METHOD_VERIFY, METHOD_CLOSE, METHOD_UNLOCK, METHOD_COMMIT_PLAN, METHOD_COMMIT} SUPPORTED_BACKENDS = {"direct", "karman_bridge"} SUPPORTED_LOCK_MODES = {"automatic", "manual"} +SUPPORTED_REPOSITORY_MODES = {"none", "manual", "automatic", "unknown"} +SUPPORTED_REPOSITORY_CONNECTION_STATES = {"not_configured", "configured", "unavailable", "unknown"} +SUPPORTED_SUPPORT_MODES = {"none", "editable", "locked", "rules", "unknown"} _BASE_LOCKS: dict[str, threading.Lock] = {} _BASE_LOCKS_GUARD = threading.Lock() _STATE_LOCK = threading.RLock() @@ -66,13 +77,60 @@ def _load_json_map(env_name: str, file_env_name: str) -> tuple[dict[str, Any] | return value, None -def repository_config(base_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: +def development_layer_id(payload: dict[str, Any] | None = None) -> str: + value = payload if isinstance(payload, dict) else {} + target = value.get("target") if isinstance(value.get("target"), dict) else {} + origin = target.get("origin") if isinstance(target.get("origin"), dict) else {} + origin_extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} + extension_guid = str(value.get("extension_guid") or target.get("extension_guid") or origin_extension.get("guid") or "").strip().lower() + layer_id = str(value.get("layer_id") or target.get("layer_id") or "").strip().lower() + if extension_guid: + return f"extension:{extension_guid}" + if layer_id.startswith("extension:"): + return layer_id + return "base" + + +def _base_settings(base_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: values, error = _load_json_map("ONEC_SQL_BASES_JSON", "ONEC_SQL_BASES_JSON_FILE") base_item = values.get(base_id) if values and isinstance(values.get(base_id), dict) else None - item = base_item.get("repository") if isinstance(base_item, dict) else None + if base_item is not None: + return base_item, None + return None, error or {"status": "not_configured", "message": f"No SQL configuration for base_id '{base_id}'."} + + +def development_layer_config(base_id: str, layer_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + base_item, base_error = _base_settings(base_id) + if base_error: + return None, base_error + layers = base_item.get("development_layers") if isinstance(base_item.get("development_layers"), dict) else None + if layers is not None: + item = layers.get(layer_id) + if not isinstance(item, dict): + return None, {"status": "layer_not_configured", "message": f"No development layer configuration for '{layer_id}' in base_id '{base_id}'."} + return dict(item), None + if layer_id == "base" and isinstance(base_item.get("repository"), dict): + return {"repository": dict(base_item["repository"]), "legacy": True}, None + return None, {"status": "layer_not_configured", "message": f"No development layer configuration for '{layer_id}' in base_id '{base_id}'."} + + +def repository_config(base_id: str, layer_id: str = "base") -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + base_item, _ = _base_settings(base_id) + layer, layer_error = development_layer_config(base_id, layer_id) + if layer_error is None: + item = layer.get("repository") + if item is None: + return {"mode": "unknown", "lock_mode": "manual", "layer_id": layer_id, "layer": layer_id}, None + else: + item = None + if isinstance(base_item, dict) and isinstance(base_item.get("development_layers"), dict): + 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") if item is None: values, repository_error = _load_json_map("ONEC_REPOSITORY_BASES_JSON", "ONEC_REPOSITORY_BASES_JSON_FILE") - if repository_error and error: + if repository_error and layer_error: return None, repository_error item = values.get(base_id) if values else None if item is None: @@ -80,9 +138,21 @@ def repository_config(base_id: str) -> tuple[dict[str, Any] | None, dict[str, An if not isinstance(item, dict): return None, {"status": "invalid_config", "message": f"Repository configuration for '{base_id}' must be an object."} configured = dict(item) + configured["mode"] = str(configured.get("mode") or configured.get("lock_mode") or "automatic").strip().casefold() + if configured["mode"] not in SUPPORTED_REPOSITORY_MODES: + return None, {"status": "invalid_config", "message": "repository mode must be none, manual, automatic, or unknown."} + default_connection_state = "unknown" if configured["mode"] == "unknown" else ("not_configured" if configured["mode"] == "none" else "configured") + configured["connection_state"] = str(configured.get("connection_state") or configured.get("repository_connection") or configured.get("connection") or default_connection_state).strip().casefold() + if configured["connection_state"] not in SUPPORTED_REPOSITORY_CONNECTION_STATES: + return None, {"status": "invalid_config", "message": "repository connection_state must be not_configured, configured, unavailable, or unknown."} + 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 configured["backend"] = str(configured.get("backend") or "direct").strip().casefold() - configured["layer"] = str(configured.get("layer") or "base").strip().casefold() - configured["lock_mode"] = str(configured.get("lock_mode") or "automatic").strip().casefold() + configured["layer_id"] = layer_id + configured["layer"] = layer_id + configured["lock_mode"] = configured["mode"] if configured["backend"] not in SUPPORTED_BACKENDS: return None, {"status": "invalid_config", "message": "repository backend must be direct or karman_bridge."} if configured["lock_mode"] not in SUPPORTED_LOCK_MODES: @@ -105,6 +175,25 @@ def repository_config(base_id: str) -> tuple[dict[str, Any] | None, dict[str, An return configured, None +def manual_confirmation_required(config: dict[str, Any]) -> bool: + """Whether SQL-only operation needs the user's exact Configurator capture confirmation.""" + if config.get("mode") in {"none", "unknown"} or config.get("connection_state") == "not_configured": + return False + return config.get("connection_state") == "unavailable" or config.get("lock_mode") == "manual" or not external_1c_enabled() + + +def manual_confirmation_next_call(base_id: str, request_id: str) -> dict[str, Any]: + """Machine-readable continuation; clients must not infer confirmation keys.""" + return { + "method": METHOD_CONFIRM, + "params": { + "base_id": base_id, + "request_id": request_id, + "user_confirmed_locked": True, + }, + } + + def _secret(config: dict[str, Any], field: str) -> str: env_name = str(config.get(f"{field}_env") or "").strip() return os.environ.get(env_name, "") if env_name else "" @@ -112,9 +201,12 @@ def _secret(config: dict[str, Any], field: str) -> str: def _public_config(config: dict[str, Any]) -> dict[str, Any]: return { + "mode": config.get("mode") or config.get("lock_mode"), + "layer_id": config.get("layer_id") or config.get("layer"), "backend": config.get("backend"), "layer": config.get("layer"), "lock_mode": config.get("lock_mode"), + "connection_state": config.get("connection_state"), "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"), @@ -134,6 +226,24 @@ def _public_config(config: dict[str, Any]) -> dict[str, Any]: } +def public_development_layers(base_id: str) -> dict[str, Any]: + base_item, error = _base_settings(base_id) + if error or not isinstance(base_item, dict): + return {} + layers = base_item.get("development_layers") if isinstance(base_item.get("development_layers"), dict) else {} + result: dict[str, Any] = {} + for layer_id, layer in layers.items(): + if not isinstance(layer, dict): + continue + repository, repository_error = repository_config(base_id, str(layer_id)) + support = layer.get("support") if isinstance(layer.get("support"), dict) else {"mode": "unknown"} + result[str(layer_id)] = { + "repository": _public_config(repository) if repository and not repository_error else {"status": "invalid", "problem": repository_error}, + "support": {"mode": str(support.get("mode") or "unknown"), "rules": dict(support.get("rules") or {}) if isinstance(support.get("rules"), dict) else {}}, + } + return result + + def _infobase_args(config: dict[str, Any]) -> list[str]: infobase = config["infobase"] if infobase.get("file"): @@ -311,24 +421,38 @@ def lock_plan(payload: dict[str, Any]) -> dict[str, Any]: "schema": "onec_repository_lock_plan.v1", "method": METHOD_LOCK_PLAN, "status": "ready" if not warnings else "needs_confirmation", + "scope_status": "resolved", "operation": operation, "requested_objects": [str(x) for x in (payload.get("objects") or [payload.get("object") or payload.get("ref") or payload.get("path")])], "lock_objects": objects, "warnings": warnings, + "native_lock_state": "unknown", + "native_lock_verification": "not_available_via_infobase_sql" if not external_1c_enabled() else "not_checked", + "capture_required": None, + "sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [], } base_id = str(payload.get("base_id") or "").strip() if base_id: - config, config_error = repository_config(base_id) + layer_id = development_layer_id(payload) + result["layer_id"] = layer_id + config, config_error = repository_config(base_id, layer_id) if config_error: result["repository_problem"] = config_error - elif config.get("lock_mode") == "manual": + elif config.get("mode") == "unknown" or config.get("connection_state") == "unknown": + result["workflow"] = "blocked" + result["status"] = "blocked_repository_connection_unknown" + result["next_method"] = None + elif config.get("mode") == "none" or config.get("connection_state") == "not_configured": + result["workflow"] = "not_required" + result["next_method"] = None + elif manual_confirmation_required(config): result["workflow"] = "manual" - result["next_method"] = METHOD_CONFIRM + result["next_method"] = METHOD_LOCK_REQUEST if not external_1c_enabled() else METHOD_CONFIRM result["user_action"] = { - "action": "lock_in_configurator", + "action": "confirm_lock_in_configurator", "base_id": base_id, "objects": objects, - "message": "Захватите перечисленные объекты в Конфигураторе, затем явно подтвердите тот же список через repository.lock.confirm.", + "message": "SQL информационной базы не показывает нативное состояние захвата. Убедитесь в Конфигураторе, что перечисленные объекты уже захвачены указанным пользователем хранилища, и явно подтвердите тот же список через repository.lock.confirm.", } else: result["workflow"] = "automatic" @@ -338,36 +462,57 @@ def lock_plan(payload: dict[str, Any]) -> dict[str, Any]: def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]: base_id = str(payload.get("base_id") or "").strip() - config, error = repository_config(base_id) + layer_id = development_layer_id(payload) + config, error = repository_config(base_id, layer_id) if error: return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, **error} plan = lock_plan(payload) + if plan.get("workflow") == "not_required": + return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, "layer_id": layer_id, "status": "not_required", "plan": plan} if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True: plan["status"] = "ready" if plan.get("status") != "ready": return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, "status": "blocked", "plan": plan} + configured_repository_user = str(config.get("repository_user") or "").strip() + requested_repository_user = str(payload.get("confirmed_repository_user") or payload.get("repository_user") or "").strip() + if requested_repository_user and configured_repository_user and requested_repository_user.casefold() != configured_repository_user.casefold(): + return { + "schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, + "base_id": base_id, "layer_id": layer_id, "status": "blocked", + "error": "repository_user_mismatch", "expected_repository_user": configured_repository_user, + } + repository_user = requested_repository_user or configured_repository_user request_id = "rreq-" + uuid.uuid4().hex with _STATE_LOCK: state = _read_state() state.setdefault("requests", {})[request_id] = { "base_id": base_id, - "layer": str(config.get("layer") or "base"), + "layer": layer_id, + "layer_id": layer_id, "backend": config.get("backend"), "operation": plan.get("operation"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "pending_user_lock", "execution": "manual", + "repository_user": repository_user, "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, "status": "pending_user_lock", "request_id": request_id, + "base_id": base_id, "layer_id": layer_id, "status": "pending_user_lock", "request_id": request_id, "objects": plan["lock_objects"], "automatically_locked": False, - "user_action": "Захватите перечисленные объекты в Конфигураторе и подтвердите заявку через repository.lock.confirm.", + "sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [], + "native_lock_state": "unknown", + "native_lock_verification": "not_available_via_infobase_sql", + "capture_required": None, + "repository_user": repository_user or None, + "repository_user_source": "layer_connection_setting" if configured_repository_user and not requested_repository_user else ("request" if requested_repository_user else "not_configured"), + "user_action": "Убедитесь в Конфигураторе, что перечисленные объекты захвачены вами под настроенным пользователем хранилища, и явно подтвердите захват через repository.lock.confirm.", "next_method": METHOD_CONFIRM, + "next_call": manual_confirmation_next_call(base_id, request_id), } @@ -376,7 +521,11 @@ 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} - return {"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, "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) + return result def cancel_lock_request(payload: dict[str, Any]) -> dict[str, Any]: @@ -449,25 +598,34 @@ def admin_state(base_id: str = "") -> dict[str, Any]: def status(payload: dict[str, Any]) -> dict[str, Any]: base_id = str(payload.get("base_id") or "").strip() - config, error = repository_config(base_id) + layer_id = development_layer_id(payload) + config, error = repository_config(base_id, layer_id) if error: return {"schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id, "connected": False, **error} result: dict[str, Any] = { - "schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id, + "schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id, "layer_id": layer_id, "status": "configured", "connected": True, "available": None, "repository": _public_config(config), + "sql_lock_evidence": SQL_LOCK_EVIDENCE, } + if config.get("mode") == "none" or config.get("connection_state") == "not_configured": + result.update({"status": "repository_not_connected", "connected": False, "available": None}) + return result + if config.get("mode") == "unknown" or config.get("connection_state") == "unknown": + result.update({"status": "repository_connection_unknown", "connected": False, "available": None}) + return result + if manual_confirmation_required(config): + result.update({"status": "manual_confirmation_required", "connected": False, "available": False if config.get("connection_state") == "unavailable" else None}) + if bool(payload.get("probe")): + result["probe"] = {"status": "not_applicable", "message": "Manual SQL-only workflow does not establish a native repository connection."} + return result if not bool(payload.get("probe")): return result if not external_1c_enabled(): result["status"] = "sql_only" + result["connected"] = False result["available"] = None result["probe"] = {"status": "not_supported", "message": "External 1C access is disabled in this SQL-only adapter version."} return result - if config.get("lock_mode") == "manual": - result["status"] = "manual_workflow" - result["available"] = None - result["probe"] = {"status": "not_applicable", "message": "Manual lock mode does not require Designer or a repository runner. Use repository.lock.plan."} - return result timeout_seconds = int(payload.get("timeout_seconds") or 60) executed = _execute_repository(base_id, config, "report", timeout_seconds) result["probe"] = executed @@ -478,10 +636,11 @@ def status(payload: dict[str, Any]) -> dict[str, Any]: def lock(payload: dict[str, Any]) -> dict[str, Any]: base_id = str(payload.get("base_id") or "").strip() - config, error = repository_config(base_id) + layer_id = development_layer_id(payload) + config, error = repository_config(base_id, layer_id) if error: return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, **error} - if config.get("lock_mode") == "manual": + if manual_confirmation_required(config): return { "schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "manual_action_required", "plan": lock_plan(payload), "next_method": METHOD_CONFIRM, @@ -502,17 +661,18 @@ def lock(payload: dict[str, Any]) -> dict[str, Any]: session_id = "rlock-" + uuid.uuid4().hex state = _read_state() sessions = state.setdefault("sessions", {}) - sessions[session_id] = {"base_id": base_id, "layer": layer, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"} + 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) 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} def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]: base_id = str(payload.get("base_id") or "").strip() - config, error = repository_config(base_id) + layer_id = development_layer_id(payload) + config, error = repository_config(base_id, layer_id) if error: return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, **error} - if config.get("lock_mode") != "manual": + if not manual_confirmation_required(config): return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "manual_lock_mode_required"} if not external_1c_enabled() and not str(payload.get("request_id") or "").strip(): return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_required", "next_method": METHOD_LOCK_REQUEST} @@ -525,31 +685,73 @@ def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]: 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} effective_payload = dict(payload) if isinstance(request, dict): + layer_id = str(request.get("layer_id") or request.get("layer") or "base") + config, error = repository_config(base_id, layer_id) + if error: + return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, **error} + if not manual_confirmation_required(config): + return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "manual_lock_mode_required"} effective_payload["objects"] = [str(item) for item in request.get("objects") or []] + effective_payload["layer_id"] = layer_id + effective_payload["sql_resolution"] = request.get("sql_resolution") if isinstance(request.get("sql_resolution"), list) else [] + if payload.get("user_confirmed_locked") is not True: + response: dict[str, Any] = { + "schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, + "base_id": base_id, "layer_id": layer_id, "status": "confirmation_required", + "error": "user_confirmed_locked_required", + "required_fields": ["base_id", "request_id", "user_confirmed_locked"], + "next_call": manual_confirmation_next_call(base_id, request_id), + } + if isinstance(request, dict): + response.update({ + "request_id": request_id, + "objects": list(request.get("objects") or []), + "repository_user": str(request.get("repository_user") or config.get("repository_user") or "") or None, + "sql_resolution": effective_payload["sql_resolution"], + }) + return response plan = lock_plan(effective_payload) if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True: plan["status"] = "ready" if plan.get("status") != "ready": return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "plan": plan} - if payload.get("user_confirmed_locked") is not True: - return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "confirmation_required", "plan": plan} + requested_repository_user = str(payload.get("confirmed_repository_user") or payload.get("repository_user") or "").strip() + expected_repository_user = str(config.get("repository_user") or "").strip() + request_repository_user = str(request.get("repository_user") or "").strip() if isinstance(request, dict) else "" + confirmed_repository_user = requested_repository_user or request_repository_user or expected_repository_user + if not confirmed_repository_user: + return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "confirmation_required", "error": "repository_user_confirmation_required", "plan": plan} + 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": str(config.get("layer") or "base"), "backend": config.get("backend"), + "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"]) + _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) return { "schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, - "status": "manual_confirmed", "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"], + "status": "manual_confirmed", "layer_id": layer_id, "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"], + "capture_state": "manually_confirmed", + "native_lock_state": "unknown", + "native_lock_verification": "not_available_via_infobase_sql", "automatically_verified": False, + "repository_user": confirmed_repository_user, + "write_context": { + "lock_session_id": session_id, + "repository_object": plan["lock_objects"][0] if len(plan["lock_objects"]) == 1 else None, + "layer_id": layer_id, + "locked_objects": plan["lock_objects"], + "instruction": "Copy lock_session_id into metadata.write.preflight and code.write. For one object, repository_object is supplied for clients that do not resolve module ownership themselves.", + }, "warning": "Адаптер принял явное подтверждение пользователя, но не проверял захват через API хранилища.", } @@ -604,20 +806,32 @@ def close_manual_lock(payload: dict[str, Any]) -> dict[str, Any]: def write_gate(payload: dict[str, Any]) -> dict[str, Any]: base_id = str(payload.get("base_id") or "").strip() - config, error = repository_config(base_id) + layer_id = development_layer_id(payload) + config, error = repository_config(base_id, layer_id) if error and error.get("status") == "not_configured": - return {"required": False, "allowed": True, "status": "not_configured"} + return {"required": False, "allowed": True, "status": "not_configured", "layer_id": layer_id} if error: - return {"required": True, "allowed": False, "status": "blocked_repository_configuration", "problem": error} + return {"required": True, "allowed": False, "status": "blocked_repository_configuration", "layer_id": layer_id, "problem": error} + if config.get("mode") == "none" or config.get("connection_state") == "not_configured": + return {"required": False, "allowed": True, "status": "repository_not_connected", "layer_id": layer_id} + if config.get("mode") == "unknown" or config.get("connection_state") == "unknown": + return {"required": True, "allowed": False, "status": "blocked_repository_connection_unknown", "layer_id": layer_id} session_id = str(payload.get("lock_session_id") or "").strip() if not session_id: return { "required": True, "allowed": False, "status": "needs_repository_lock", - "backend": config.get("backend"), "next_method": METHOD_LOCK_PLAN, + "backend": config.get("backend"), "layer_id": layer_id, "next_method": METHOD_LOCK_PLAN, } session = (_read_state().get("sessions") or {}).get(session_id) - if not isinstance(session, dict) or session.get("base_id") != base_id or session.get("status") not in {"acquired", "manual_confirmed"}: - return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "lock_session_id": session_id} + if not isinstance(session, dict): + return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_session_not_found", "layer_id": layer_id, "lock_session_id": session_id} + if session.get("base_id") != base_id: + return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_session_base_mismatch", "layer_id": layer_id, "lock_session_id": session_id, "session_base_id": session.get("base_id")} + session_layer_id = str(session.get("layer_id") or session.get("layer") or "base") + if session_layer_id != layer_id: + return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_layer_mismatch", "layer_id": layer_id, "session_layer_id": session_layer_id, "lock_session_id": session_id} + if session.get("status") not in {"acquired", "manual_confirmed"}: + return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_session_inactive", "layer_id": layer_id, "lock_session_id": session_id, "session_status": session.get("status")} target = payload.get("target") if isinstance(payload.get("target"), dict) else {} requested = "" for value in ( @@ -641,15 +855,67 @@ def write_gate(payload: dict[str, Any]) -> dict[str, Any]: if requested.casefold() not in {item.casefold() for item in locked}: return { "required": True, "allowed": False, "status": "blocked_repository_scope_mismatch", - "lock_session_id": session_id, "requested_object": requested, "locked_objects": locked, + "error": "lock_object_mismatch", "lock_session_id": session_id, "requested_object": requested, "locked_objects": locked, } return { - "required": True, "allowed": True, "status": "ready", "backend": config.get("backend"), + "required": True, "allowed": True, "status": "ready", "backend": config.get("backend"), "layer_id": layer_id, "lock_session_id": session_id, "requested_object": requested, "objects": locked, "verification": "automatic" if session.get("status") == "acquired" else "user_confirmation_only", } +def support_gate(payload: dict[str, Any]) -> dict[str, Any]: + """Evaluate the independently configured support policy for one exact target. + + Repository scope may collapse a child to its development owner. Support scope + intentionally does not: a form or another child can have its own support rule. + """ + base_id = str(payload.get("base_id") or "").strip() + layer_id = development_layer_id(payload) + base_item, base_error = _base_settings(base_id) + if base_error: + if base_error.get("status") == "not_configured": + return {"required": False, "allowed": True, "status": "support_not_configured_legacy", "layer_id": layer_id, "source": "legacy_configuration"} + return {"required": True, "allowed": False, "status": "blocked_support_configuration", "layer_id": layer_id, "problem": base_error} + layers = base_item.get("development_layers") if isinstance(base_item.get("development_layers"), dict) else None + if layers is None: + 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): + return {"required": True, "allowed": False, "status": "blocked_support_layer_unknown", "layer_id": layer_id} + support = layer.get("support") + if not isinstance(support, dict): + return {"required": True, "allowed": False, "status": "blocked_support_unknown", "layer_id": layer_id, "source": "development_layer_configuration"} + mode = str(support.get("mode") or "unknown").strip().casefold() + if mode not in SUPPORTED_SUPPORT_MODES: + return {"required": True, "allowed": False, "status": "blocked_support_configuration", "layer_id": layer_id, "problem": {"message": "support.mode must be none, editable, locked, rules, or unknown."}} + if mode == "none": + return {"required": False, "allowed": True, "status": "not_on_support", "layer_id": layer_id, "mode": mode} + if mode == "editable": + return {"required": True, "allowed": True, "status": "support_editable", "layer_id": layer_id, "mode": mode} + if mode == "locked": + return {"required": True, "allowed": False, "status": "blocked_by_support", "layer_id": layer_id, "mode": mode} + target = payload.get("target") if isinstance(payload.get("target"), dict) else {} + scope = str(target.get("support_scope") or target.get("canonical_path") or target.get("path") or payload.get("support_scope") or payload.get("canonical_path") or payload.get("path") or payload.get("ref") or payload.get("object") or "").strip() + if mode == "rules" and scope: + rules = support.get("rules") if isinstance(support.get("rules"), dict) else {} + candidates = [] + folded = scope.casefold() + for rule_scope, rule_value in rules.items(): + key = str(rule_scope).strip() + if key and (folded == key.casefold() or folded.startswith(key.casefold() + ".")): + candidates.append((len(key), key, str(rule_value or "unknown").strip().casefold())) + if candidates: + _, matched_scope, rule = max(candidates) + allowed = rule in {"editable", "vendor_editable_support_preserved", "not_supported"} + return { + "required": True, "allowed": allowed, + "status": "support_rule_editable" if allowed else ("blocked_by_support_rule" if rule in {"locked", "vendor_not_editable"} else "blocked_support_unknown"), + "layer_id": layer_id, "mode": mode, "scope": scope, "matched_scope": matched_scope, "rule": rule, + } + return {"required": True, "allowed": False, "status": "blocked_support_unknown", "layer_id": layer_id, "mode": mode, "scope": scope or None} + + def commit_plan(payload: dict[str, Any]) -> dict[str, Any]: session_id = str(payload.get("lock_session_id") or "").strip() session = (_read_state().get("sessions") or {}).get(session_id) @@ -679,7 +945,7 @@ def commit(payload: dict[str, Any]) -> dict[str, Any]: state = _read_state() session = (state.get("sessions") or {}).get(session_id) base_id = str(session.get("base_id") or "") - config, error = repository_config(base_id) + config, error = repository_config(base_id, str(session.get("layer_id") or session.get("layer") or "base")) if error: return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "lock_session_id": session_id, **error} with _base_lock(base_id, str(session.get("layer") or "base")): @@ -704,7 +970,7 @@ def unlock(payload: dict[str, Any]) -> dict[str, Any]: if not payload.get("allow_repository_unlock") is True: return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "explicit_repository_unlock_required", "lock_session_id": session_id} base_id = str(session.get("base_id") or "") - config, error = repository_config(base_id) + config, error = repository_config(base_id, str(session.get("layer_id") or session.get("layer") or "base")) if error: return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "lock_session_id": session_id, **error} with _base_lock(base_id, str(session.get("layer") or "base")): diff --git a/plugins/1c/mcp/adapter_1c_mcp.py b/plugins/1c/mcp/adapter_1c_mcp.py index 2c4262c..8c3a902 100644 --- a/plugins/1c/mcp/adapter_1c_mcp.py +++ b/plugins/1c/mcp/adapter_1c_mcp.py @@ -104,6 +104,18 @@ OBJECT_SELECTOR_SCHEMA_PROPERTIES = { } CACHE_POLICIES = {"none", "ttl", "snapshot", "stale_while_revalidate"} SOURCE_STATES = {"applied", "working", "all"} +CONFIGURATION_VIEW_TO_SOURCE_STATE = { + "effective": "working", + "effective_working": "working", + "designer": "working", + "working": "working", + "runtime": "applied", + "runtime_applied": "applied", + "applied": "applied", + "compare": "all", + "comparison": "all", + "both": "all", +} REST_STATE_BY_SOURCE_STATE = { "applied": "active", "working": "working", @@ -176,14 +188,24 @@ TOOLS = [ "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. " + "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 programming in Configurator/designer, use source_state=working to read saved-but-not-applied metadata; " - "use source_state=applied for active metadata or source_state=all to compare both. " + "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. " "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 " + "status=pending_user_lock, do not invent confirmation fields. After the user confirms the exact object is " + "captured in Configurator, call the returned next_call.method using next_call.params as this tool's payload. " + "The successful confirmation returns write_context; forward it unchanged as payload.repository_lock (or copy its fields to the payload top level) for write preflight and write calls. " "For BSL edits, prefer high-level metadata.write: pass a 1C canonical path, routine_text, and routine_operation; " "the adapter prepares saved-state when needed, saves into working/saved-state metadata, and never activates it. " + "For a scheduled-job schedule, use metadata.write with target.kind=schedule and target.ref such as РегламентныеЗадания.ОбменДанными; pass named schedule fields instead of GUIDs or tree paths. " "For adding a form command with a visible button and handler routine, use metadata.form.command_button.write. " "Use code.write only as a compatibility shortcut for simple module edits. " "Long metadata calls return a job_id quickly; poll it with method mcp.job.get." @@ -362,6 +384,16 @@ TOOLS = [ "routine_text": "", }, }, + { + "method": "metadata.write", + "payload": { + "base_id": "", + "target": {"kind": "schedule", "ref": "РегламентныеЗадания."}, + "schedule": {"begin_time": "09:00:00", "week_days": [1, 2, 3, 4, 5]}, + "allow_saved_state_write": True, + "mode": "plan", + }, + }, { "method": "metadata.form.command_button.write", "payload": { @@ -417,9 +449,9 @@ TOOLS = [ "method": "metadata.saved_state.prepare", "payload": { "base_id": "", - "target_table": "ConfigCASSave", - "object_type": "", - "object_name": "", + "layer": "extension_saved_state", + "ref": ".", + "extension": "", "mode": "plan", }, }, @@ -427,8 +459,9 @@ TOOLS = [ "method": "metadata.saved_state.diff", "payload": { "base_id": "", - "table": "ConfigCASSave", - "file_name": "", + "ref": ".", + "module_ordinal": 1, + "extension": "", "max_text_diff_lines": 80, }, }, @@ -436,7 +469,7 @@ TOOLS = [ "method": "metadata.saved_state.status", "payload": { "base_id": "", - "table": "ConfigCASSave", + "layer": "extension_saved_state", "limit": 200, }, }, @@ -444,6 +477,7 @@ TOOLS = [ "method": "metadata.saved_state.changes.list", "payload": { "base_id": "", + "layer": "extension_saved_state", "limit": 200, "include_context": True, "group_by_context": True, @@ -990,15 +1024,17 @@ TOOLS = [ }, { "name": "access_object_explain", - "description": "Explain who can see a BSP-protected object/record by resolving object access keys to groups, user sets, and users.", + "description": "Explain who can see a BSP-protected data record. Prefer public object_ref plus record_ref; legacy raw BSP fields remain available.", "inputSchema": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, - "object": {"type": "string", "description": "Normalized object ref from access_object_keys.object."}, - "object_id": {"type": "string", "description": "Data record id from access_object_keys.object_id."}, - "object_sql_number": {"type": "integer"}, + "object_ref": {"type": "string", "description": "Public metadata ref, for example Справочники.Номенклатура."}, + "record_ref": {"type": "string", "description": "Concrete 1C application-data record reference."}, + "object": {"type": "string", "description": "Legacy raw BSP object value."}, + "object_id": {"type": "string", "description": "Legacy raw BSP object_id; prefer record_ref."}, + "object_sql_number": {"type": "integer", "description": "Legacy physical selector; prefer object_ref."}, "access_key": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000}, "subject_limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000}, @@ -1010,20 +1046,25 @@ TOOLS = [ }, { "name": "access_keys_query", - "description": "Page through BSP access key registers by group, user set, object, access set, or all. Use for data restriction diagnostics, not for metadata-object role rights.", + "description": "Page through BSP access key registers. kind selects the query area, not a metadata kind; in object mode prefer object_ref plus optional record_ref.", "inputSchema": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, - "kind": {"type": "string", "description": "group, user, object, access_set, or all."}, + "kind": {"type": "string", "description": "Query area: group, user_set, object, set, or all. This is not a 1C metadata kind."}, + "area": {"type": "string", "description": "Alias of kind."}, "group": {"type": "string"}, "user": {"type": "string"}, "user_set": {"type": "string"}, - "object": {"type": "string"}, - "object_id": {"type": "string"}, - "object_sql_number": {"type": "integer"}, + "object_ref": {"type": "string", "description": "Public metadata ref, for example Справочники.Номенклатура."}, + "record_ref": {"type": "string", "description": "Concrete 1C application-data record reference."}, + "object": {"type": "string", "description": "Legacy raw BSP object value."}, + "object_id": {"type": "string", "description": "Legacy raw BSP object_id; prefer record_ref."}, + "object_sql_number": {"type": "integer", "description": "Legacy physical selector; prefer object_ref."}, "access_key": {"type": "string"}, + "resolve_records": {"type": "boolean", "default": False}, + "max_resolved_records": {"type": "integer", "minimum": 0, "maximum": 5000, "default": 200}, "limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000}, "offset": {"type": "integer", "minimum": 0, "default": 0}, "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120}, @@ -1033,15 +1074,17 @@ TOOLS = [ }, { "name": "access_object_keys_resolve", - "description": "Return BSP object access-key rows and resolve object records when possible. Accepts object names/public refs and internal ids.", + "description": "Return BSP object access-key rows and resolve record presentations. Prefer public object_ref plus optional record_ref.", "inputSchema": { "type": "object", "required": ["base_id"], "properties": { "base_id": {"type": "string", "description": "Concrete 1C base id."}, - "object": {"type": "string"}, - "object_id": {"type": "string"}, - "object_sql_number": {"type": "integer"}, + "object_ref": {"type": "string", "description": "Public metadata ref, for example Справочники.Номенклатура."}, + "record_ref": {"type": "string", "description": "Concrete 1C application-data record reference."}, + "object": {"type": "string", "description": "Legacy raw BSP object value."}, + "object_id": {"type": "string", "description": "Legacy raw BSP object_id; prefer record_ref."}, + "object_sql_number": {"type": "integer", "description": "Legacy physical selector; prefer object_ref."}, "access_key": {"type": "string"}, "limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000}, "offset": {"type": "integer", "minimum": 0, "default": 0}, @@ -1211,7 +1254,22 @@ def normalize_source_state(value: Any, source_mode: str) -> str: return "working" if value_text in SOURCE_STATES: return value_text - return "working" if source_mode == "designer" else "applied" + # Agents program against the logical Designer configuration by default. + # Runtime remains explicit through source_mode=runtime or source_state=applied. + return "applied" if source_mode == "runtime" else "working" + + +def normalize_configuration_view(value: Any, source_state: str) -> tuple[str, str]: + view = str(value or "").strip().casefold() + if not view: + return ("effective_working" if source_state == "working" else "runtime_applied" if source_state == "applied" else "compare", source_state) + mapped = CONFIGURATION_VIEW_TO_SOURCE_STATE.get(view) + if not mapped: + return "", "" + if source_state and source_state != mapped: + return "", "" + canonical = "effective_working" if mapped == "working" else "runtime_applied" if mapped == "applied" else "compare" + return canonical, mapped def normalize_cache_policy(value: Any, source_mode: str) -> str: @@ -2213,7 +2271,15 @@ def apply_freshness_request_policy(payload: dict[str, Any], method: str) -> dict if method == "code.write": source_mode = "designer" cache_policy = normalize_cache_policy(payload.get("cache_policy"), source_mode) - source_state = normalize_source_state(payload.get("source_state"), source_mode) + explicit_source_state = normalize_source_state(payload.get("source_state"), source_mode) if payload.get("source_state") is not None else "" + configuration_view, source_state = normalize_configuration_view(payload.get("configuration_view"), explicit_source_state) + if not configuration_view: + transformed = dict(payload) + transformed["_configuration_view_error"] = "configuration_view conflicts with source_state or is unsupported." + return transformed + if not source_state: + source_state = normalize_source_state(None, source_mode) + configuration_view, source_state = normalize_configuration_view(payload.get("configuration_view"), source_state) if method == "code.write": source_state = "working" cache_policy = "none" @@ -2221,6 +2287,7 @@ def apply_freshness_request_policy(payload: dict[str, Any], method: str) -> dict transformed = dict(payload) transformed["source_mode"] = source_mode transformed["source_state"] = source_state + transformed["configuration_view"] = configuration_view transformed["cache_policy"] = cache_policy if method in REST_STATE_METHODS and "state" not in transformed: transformed["state"] = REST_STATE_BY_SOURCE_STATE.get(source_state, "working") @@ -2878,7 +2945,19 @@ def metadata_write_code_guardrail(method: str, payload: dict[str, Any]) -> dict[ def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any: request_start = now_ts() request_id = uuid.uuid4().hex + if method == "code.search" and payload.get("extension_guid") in {None, ""}: + payload = dict(payload) + payload.pop("extension_guid", None) request_payload = apply_freshness_request_policy(payload, method) + configuration_view_error = request_payload.pop("_configuration_view_error", None) + if configuration_view_error: + return { + "schema": "adapter_1c_mcp_policy.v1", + "status": "invalid_argument", + "method": method, + "error": "invalid_configuration_view", + "diagnostics": {"message": str(configuration_view_error)}, + } request_payload["_mcp_request_id"] = request_id payload = request_payload code_guardrail = metadata_write_code_guardrail(method, payload) diff --git a/plugins/1c/parser/README.md b/plugins/1c/parser/README.md index 5be5b3c..03e2260 100644 --- a/plugins/1c/parser/README.md +++ b/plugins/1c/parser/README.md @@ -20,6 +20,10 @@ infobase. - `xml_metadata.py`: small XML metadata extractor used as validation oracle. - `structured_metadata.py`: evidence-based projection from Config payloads to normalized metadata records. +- `common_command.py`: adapter-independent reverse index from + `CommonCommand.Group` to command-group membership. +- `scheduled_job.py`: adapter-independent scheduled-job schedule decoder, + named-field validator, and verified tree rebuilder. ## Current Guarantees @@ -42,6 +46,10 @@ The parser can currently: evidence paths. - attach child metadata items to concrete section record paths when a declared child-record container is present. +- resolve command-group membership from CommonCommand payloads without + requiring callers to know GUIDs or storage paths. +- decode scheduled-job schedule payloads and build guarded named-field edits, + including weekday/month collection resize without exposing tree paths. ## Non-Goals At This Layer diff --git a/plugins/1c/parser/__init__.py b/plugins/1c/parser/__init__.py index 2f1709b..fd94f87 100644 --- a/plugins/1c/parser/__init__.py +++ b/plugins/1c/parser/__init__.py @@ -9,7 +9,13 @@ from .payload import ( payload_to_text, try_decompress, ) -from .dbnames import DBNamesRecord, parse_dbnames_bytes, parse_dbnames_file +from .dbnames import ( + DBNamesRecord, + parse_dbnames_bytes, + parse_dbnames_file, + parse_dbnames_version_bytes, + parse_dbnames_version_file, +) from .extensions import ( ExtensionZippedInfo, ManifestEntry, @@ -20,6 +26,23 @@ from .config_object import MetadataObjectIdentity, find_identity, parse_config_o from .storage import StorageRoute, group_records_by_guid, storage_route, storage_routes from .config_sections import SectionSummary, summarize_section, summarize_sections from .xml_metadata import XmlMetadataItem, extract_xml_metadata_items, group_xml_items +from .support_rules import ( + SupplierSupport, + SupportRule, + parse_parent_configurations_bytes, + parse_parent_configurations_file, +) +from .common_command import ( + common_command_group_guid, + index_common_command_groups, + parse_common_command_tree, +) +from .scheduled_job import ( + decode_schedule, + rebuild_schedule_tree, + schedule_layout, + schedule_write_edits, +) __all__ = [ "BraceNode", @@ -32,6 +55,8 @@ __all__ = [ "DBNamesRecord", "parse_dbnames_bytes", "parse_dbnames_file", + "parse_dbnames_version_bytes", + "parse_dbnames_version_file", "ExtensionZippedInfo", "ManifestEntry", "parse_extension_manifest_bytes", @@ -49,4 +74,15 @@ __all__ = [ "XmlMetadataItem", "extract_xml_metadata_items", "group_xml_items", + "SupplierSupport", + "SupportRule", + "parse_parent_configurations_bytes", + "parse_parent_configurations_file", + "common_command_group_guid", + "index_common_command_groups", + "parse_common_command_tree", + "decode_schedule", + "rebuild_schedule_tree", + "schedule_layout", + "schedule_write_edits", ] diff --git a/plugins/1c/parser/common_command.py b/plugins/1c/parser/common_command.py new file mode 100644 index 0000000..0608a41 --- /dev/null +++ b/plugins/1c/parser/common_command.py @@ -0,0 +1,89 @@ +"""Pure reverse index for 1C common-command group membership.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable, Mapping +from typing import Any + +from .payload import decode_payload_lossless, parse_brace_text + + +_GUID_RE = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) + + +def _tree_item_at_path(tree: Any, path: tuple[int, ...]) -> Any | None: + node = tree + for index in path: + if not isinstance(node, dict) or not isinstance(node.get("items"), list): + return None + items = node["items"] + if index < 0 or index >= len(items): + return None + node = items[index] + return node + + +def _tree_scalar_at_path(tree: Any, path: tuple[int, ...]) -> str: + node = _tree_item_at_path(tree, path) + if isinstance(node, dict) and node.get("type") in {"atom", "string"}: + return str(node.get("value") or "") + return "" + + +def common_command_group_guid(tree: Any) -> str | None: + """Return the group GUID stored in an observed CommonCommand Config tree.""" + body = _tree_item_at_path(tree, (1, 1, 2)) + group_guid = _tree_scalar_at_path(body, (7, 1)).strip().lower() + return group_guid if _GUID_RE.fullmatch(group_guid) else None + + +def parse_common_command_tree(data: bytes) -> Any | None: + """Decode a CommonCommand payload without raising on unsupported data.""" + try: + decoded = decode_payload_lossless(data) + text = decoded.get("text") + if not text or "{" not in text: + return None + return parse_brace_text(text) + except Exception: + return None + + +def index_common_command_groups( + commands: Iterable[Mapping[str, Any]], + payloads: Mapping[str, bytes], +) -> dict[str, Any]: + """Build ``CommandGroup GUID -> CommonCommand rows`` from Config payloads.""" + command_rows = [dict(item) for item in commands] + normalized_payloads = {str(key).strip().lower(): value for key, value in payloads.items()} + groups: dict[str, list[dict[str, Any]]] = {} + source_missing = 0 + undecodable = 0 + unassigned = 0 + + for item in command_rows: + guid = str(item.get("guid") or "").strip().lower() + data = normalized_payloads.get(guid) + if not data: + source_missing += 1 + continue + tree = parse_common_command_tree(data) + if tree is None: + undecodable += 1 + continue + group_guid = common_command_group_guid(tree) + if group_guid is None: + unassigned += 1 + continue + groups.setdefault(group_guid, []).append(item) + + return { + "groups": groups, + "scanned": len(command_rows), + "source_missing": source_missing, + "undecodable": undecodable, + "unassigned": unassigned, + } diff --git a/plugins/1c/parser/dbnames.py b/plugins/1c/parser/dbnames.py index a3ce8f6..24a1cb5 100644 --- a/plugins/1c/parser/dbnames.py +++ b/plugins/1c/parser/dbnames.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import asdict, dataclass from pathlib import Path +import re from typing import Any from .payload import parse_brace_text, payload_to_text, scalar @@ -77,3 +78,44 @@ def parse_dbnames_bytes(data: bytes, *, source: str = "DBNames") -> dict[str, An def parse_dbnames_file(path: Path) -> dict[str, Any]: return parse_dbnames_bytes(path.read_bytes(), source=path.name) + + +_GUID_RE = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) + + +def parse_dbnames_version_bytes(data: bytes, *, source: str = "DBNamesVersion") -> dict[str, Any]: + """Parse the version marker stored separately from DBNames records.""" + decoded = payload_to_text(data) + text = decoded.get("text") + if text is None: + raise ValueError(f"{source}: cannot decode DBNamesVersion text") + parsed = _unwrap_bom_sequence(parse_brace_text(text)) + if not (isinstance(parsed, dict) and parsed.get("type") == "list"): + raise ValueError(f"{source}: expected root list") + items = parsed.get("items") or [] + if len(items) != 2: + raise ValueError(f"{source}: expected 2 root items, got {len(items)}") + if not all(isinstance(item, dict) and item.get("type") == "atom" for item in items): + actual_types = [item.get("type") if isinstance(item, dict) else type(item).__name__ for item in items] + raise ValueError(f"{source}: expected scalar marker and version, got {actual_types}") + marker_text = scalar(items[0]).strip() + try: + marker = int(marker_text) + except (TypeError, ValueError) as exc: + raise ValueError(f"{source}: marker must be an integer, got {marker_text!r}") from exc + version = scalar(items[1]).strip().lower() + if not _GUID_RE.fullmatch(version): + raise ValueError(f"{source}: version must be a GUID string, got {version!r}") + return { + "source": source, + "compression": decoded["compression"], + "encoding": decoded["encoding"], + "marker": marker, + "version": version, + } + + +def parse_dbnames_version_file(path: Path) -> dict[str, Any]: + return parse_dbnames_version_bytes(path.read_bytes(), source=path.name) diff --git a/plugins/1c/parser/scheduled_job.py b/plugins/1c/parser/scheduled_job.py new file mode 100644 index 0000000..19bdda1 --- /dev/null +++ b/plugins/1c/parser/scheduled_job.py @@ -0,0 +1,451 @@ +"""Pure decoder and safe tree editor for 1C scheduled-job schedules.""" + +from __future__ import annotations + +import copy +import re +from datetime import datetime +from typing import Any + + +WRITABLE_SCALAR_FIELDS = { + "begin_date", + "end_date", + "begin_time", + "end_time", + "completion_time", + "completion_interval", + "repeat_period_in_day", + "repeat_pause", + "week_day_in_month", + "day_in_month", + "weeks_period", + "days_repeat_period", +} +WRITABLE_LIST_FIELDS = {"week_days", "months"} +INTEGER_RANGES = { + "completion_interval": (0, 2_147_483_647), + "repeat_period_in_day": (0, 2_147_483_647), + "repeat_pause": (0, 2_147_483_647), + "week_day_in_month": (0, 5), + "day_in_month": (0, 31), + "weeks_period": (0, 2_147_483_647), + "days_repeat_period": (0, 2_147_483_647), +} + + +def _scalar(node: Any) -> str: + if not isinstance(node, dict): + return str(node or "") + if node.get("type") in {"atom", "string"}: + return str(node.get("value") or "") + return "" + + +def schedule_datetime(raw: str) -> tuple[str | None, str | None]: + if not re.fullmatch(r"\d{14}", raw): + return None, None + return ( + f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]}", + f"{raw[8:10]}:{raw[10:12]}:{raw[12:14]}", + ) + + +def schedule_layout(tree: Any) -> dict[str, Any]: + items = tree.get("items") if isinstance(tree, dict) and isinstance(tree.get("items"), list) else [] + raw = [_scalar(item) for item in items] + if len(raw) < 13: + return { + "status": "invalid_schedule_payload", + "diagnostics": {"message": "The SQL schedule payload is shorter than the supported format."}, + } + week_day_count = int(raw[8]) if re.fullmatch(r"-?\d+", raw[8]) else None + cursor = 9 + if week_day_count is None or week_day_count < 0 or cursor + week_day_count > len(raw): + return { + "status": "invalid_schedule_payload", + "diagnostics": {"message": "Invalid weekday collection in the SQL schedule payload."}, + } + week_days_start = cursor + cursor += week_day_count + if cursor + 3 > len(raw): + return { + "status": "invalid_schedule_payload", + "diagnostics": {"message": "The SQL schedule payload has no month collection header."}, + } + week_day_in_month_index = cursor + day_in_month_index = cursor + 1 + month_count_index = cursor + 2 + month_count = int(raw[month_count_index]) if re.fullmatch(r"-?\d+", raw[month_count_index]) else None + cursor += 3 + if month_count is None or month_count < 0 or cursor + month_count > len(raw): + return { + "status": "invalid_schedule_payload", + "diagnostics": {"message": "Invalid month collection in the SQL schedule payload."}, + } + months_start = cursor + cursor += month_count + if cursor + 2 > len(raw): + return { + "status": "invalid_schedule_payload", + "diagnostics": {"message": "The SQL schedule payload has no repeat-period tail."}, + } + return { + "status": "ok", + "raw": raw, + "indexes": { + "begin_date": 0, + "end_date": 1, + "begin_time": 2, + "end_time": 3, + "completion_time": 4, + "completion_interval": 5, + "repeat_period_in_day": 6, + "repeat_pause": 7, + "week_day_count": 8, + "week_days": list(range(week_days_start, week_days_start + week_day_count)), + "week_day_in_month": week_day_in_month_index, + "day_in_month": day_in_month_index, + "month_count": month_count_index, + "months": list(range(months_start, months_start + month_count)), + "weeks_period": cursor, + "days_repeat_period": cursor + 1, + }, + "cursor": cursor + 2, + } + + +def decode_schedule(tree: Any, *, include_storage: bool = False) -> dict[str, Any]: + layout = schedule_layout(tree) + if layout.get("status") != "ok": + return layout + raw = layout["raw"] + indexes = layout["indexes"] + + def integer(field: str) -> int | None: + value = raw[indexes[field]] + return int(value) if re.fullmatch(r"-?\d+", value) else None + + begin_date, _ = schedule_datetime(raw[indexes["begin_date"]]) + end_date, _ = schedule_datetime(raw[indexes["end_date"]]) + _, begin_time = schedule_datetime(raw[indexes["begin_time"]]) + _, end_time = schedule_datetime(raw[indexes["end_time"]]) + _, completion_time = schedule_datetime(raw[indexes["completion_time"]]) + result = { + "status": "ok", + "begin_date": begin_date, + "end_date": end_date, + "begin_time": begin_time, + "end_time": end_time, + "completion_time": completion_time, + "completion_interval": integer("completion_interval"), + "repeat_period_in_day": integer("repeat_period_in_day"), + "repeat_pause": integer("repeat_pause"), + "week_days": [int(raw[index]) for index in indexes["week_days"] if raw[index].isdigit()], + "week_day_in_month": integer("week_day_in_month"), + "day_in_month": integer("day_in_month"), + "months": [int(raw[index]) for index in indexes["months"] if raw[index].isdigit()], + "weeks_period": integer("weeks_period"), + "days_repeat_period": integer("days_repeat_period"), + "evidence": "live_sql_config_schedule_decoder", + } + if include_storage: + result["storage"] = { + "format": "scheduled_job_config_suffix_0", + "raw_values": raw, + "trailing_values": raw[int(layout["cursor"]) :], + } + return result + + +def rebuild_schedule_tree(tree: Any, requested: dict[str, Any]) -> dict[str, Any]: + layout = schedule_layout(tree) + if layout.get("status") != "ok": + return layout + raw = list(layout["raw"]) + indexes = layout["indexes"] + current = decode_schedule(tree) + + for field in WRITABLE_SCALAR_FIELDS: + if field not in requested: + continue + value = requested[field] + index = int(indexes[field]) + if field in {"begin_date", "end_date"}: + raw[index] = str(value).replace("-", "") + ( + raw[index][8:] if re.fullmatch(r"\d{14}", raw[index]) else "000000" + ) + elif field in {"begin_time", "end_time", "completion_time"}: + raw[index] = ( + raw[index][:8] if re.fullmatch(r"\d{14}", raw[index]) else "00010101" + ) + str(value).replace(":", "") + else: + raw[index] = str(value) + + week_days = list(requested.get("week_days", current.get("week_days") or [])) + months = list(requested.get("months", current.get("months") or [])) + rebuilt_raw = [ + *raw[:8], + str(len(week_days)), + *(str(value) for value in week_days), + raw[int(indexes["week_day_in_month"])], + raw[int(indexes["day_in_month"])], + str(len(months)), + *(str(value) for value in months), + raw[int(indexes["weeks_period"])], + raw[int(indexes["days_repeat_period"])], + *raw[int(layout["cursor"]) :], + ] + rebuilt_tree = copy.deepcopy(tree) + rebuilt_tree["items"] = [{"type": "atom", "value": value} for value in rebuilt_raw] + verification = decode_schedule(rebuilt_tree) + if verification.get("status") != "ok": + return { + "status": "error", + "error": "schedule_rebuild_verification_failed", + "diagnostics": {"message": "The rebuilt scheduled-job tree did not pass the schedule decoder."}, + } + for field, expected in requested.items(): + if verification.get(field) != expected: + return { + "status": "error", + "error": "schedule_rebuild_verification_failed", + "field": field, + "diagnostics": { + "message": "A named schedule field changed during tree rebuild verification.", + "expected": expected, + "actual": verification.get(field), + }, + } + return { + "status": "ok", + "tree": rebuilt_tree, + "schedule": verification, + "old_counts": { + "week_days": len(indexes["week_days"]), + "months": len(indexes["months"]), + }, + "new_counts": { + "week_days": len(week_days), + "months": len(months), + }, + } + + +def schedule_write_edits(tree: Any, requested: Any) -> dict[str, Any]: + if not isinstance(requested, dict) or not requested: + return { + "status": "invalid_argument", + "error": "schedule_required", + "diagnostics": {"message": "schedule must be a non-empty JSON object."}, + } + unknown = sorted(set(requested) - WRITABLE_SCALAR_FIELDS - WRITABLE_LIST_FIELDS) + if unknown: + return { + "status": "invalid_argument", + "error": "unsupported_schedule_fields", + "diagnostics": { + "message": "The request contains unsupported scheduled-job fields.", + "fields": unknown, + "allowed_fields": sorted(WRITABLE_SCALAR_FIELDS | WRITABLE_LIST_FIELDS), + }, + } + layout = schedule_layout(tree) + if layout.get("status") != "ok": + return layout + current = decode_schedule(tree) + raw = layout["raw"] + indexes = layout["indexes"] + normalized: dict[str, Any] = {} + edits: list[dict[str, Any]] = [] + resized_collections: list[str] = [] + + for field, value in requested.items(): + if field in {"begin_date", "end_date"}: + if value is None: + compact = "00010101" + elif isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): + try: + datetime.fromisoformat(value) + except ValueError: + return { + "status": "invalid_argument", + "error": "invalid_schedule_value", + "field": field, + "diagnostics": {"message": f"{field} must be a real ISO date YYYY-MM-DD."}, + } + compact = value.replace("-", "") + else: + return { + "status": "invalid_argument", + "error": "invalid_schedule_value", + "field": field, + "diagnostics": {"message": f"{field} must be an ISO date YYYY-MM-DD or null."}, + } + index = int(indexes[field]) + encoded = compact + (raw[index][8:] if re.fullmatch(r"\d{14}", raw[index]) else "000000") + normalized[field] = "0001-01-01" if value is None else value + if encoded != raw[index]: + edits.append( + { + "path": str(index), + "value": encoded, + "node_type": "atom", + "expected_old": raw[index], + "field": field, + } + ) + continue + if field in {"begin_time", "end_time", "completion_time"}: + if value is None: + compact = "000000" + elif isinstance(value, str) and re.fullmatch(r"\d{2}:\d{2}:\d{2}", value): + try: + datetime.strptime(value, "%H:%M:%S") + except ValueError: + return { + "status": "invalid_argument", + "error": "invalid_schedule_value", + "field": field, + "diagnostics": {"message": f"{field} must be a real time HH:MM:SS."}, + } + compact = value.replace(":", "") + else: + return { + "status": "invalid_argument", + "error": "invalid_schedule_value", + "field": field, + "diagnostics": {"message": f"{field} must be a time HH:MM:SS or null."}, + } + index = int(indexes[field]) + encoded = ( + raw[index][:8] if re.fullmatch(r"\d{14}", raw[index]) else "00010101" + ) + compact + normalized[field] = "00:00:00" if value is None else value + if encoded != raw[index]: + edits.append( + { + "path": str(index), + "value": encoded, + "node_type": "atom", + "expected_old": raw[index], + "field": field, + } + ) + continue + if field in WRITABLE_LIST_FIELDS: + maximum = 7 if field == "week_days" else 12 + if ( + not isinstance(value, list) + or any( + isinstance(item, bool) + or not isinstance(item, int) + or item < 1 + or item > maximum + for item in value + ) + or len(set(value)) != len(value) + ): + return { + "status": "invalid_argument", + "error": "invalid_schedule_value", + "field": field, + "diagnostics": { + "message": f"{field} must be a JSON array of unique integers from 1 to {maximum}." + }, + } + field_indexes = list(indexes[field]) + if len(value) != len(field_indexes): + resized_collections.append(field) + normalized[field] = list(value) + continue + normalized[field] = list(value) + for index, item in zip(field_indexes, value): + encoded = str(item) + if encoded != raw[index]: + edits.append( + { + "path": str(index), + "value": encoded, + "node_type": "atom", + "expected_old": raw[index], + "field": field, + } + ) + continue + minimum, maximum = INTEGER_RANGES[field] + if isinstance(value, bool) or not isinstance(value, int) or value < minimum or value > maximum: + return { + "status": "invalid_argument", + "error": "invalid_schedule_value", + "field": field, + "diagnostics": { + "message": f"{field} must be a JSON integer from {minimum} to {maximum}." + }, + } + index = int(indexes[field]) + encoded = str(value) + normalized[field] = value + if encoded != raw[index]: + edits.append( + { + "path": str(index), + "value": encoded, + "node_type": "atom", + "expected_old": raw[index], + "field": field, + } + ) + + if resized_collections: + rebuilt = rebuild_schedule_tree(tree, normalized) + if rebuilt.get("status") != "ok": + return rebuilt + edits = [ + { + "replace_root": rebuilt["tree"], + "fields": sorted(normalized), + "resized_collections": sorted(resized_collections), + "old_counts": rebuilt["old_counts"], + "new_counts": rebuilt["new_counts"], + } + ] + return { + "status": "ok", + "current": current, + "requested": normalized, + "edits": edits, + "counts": { + "requested_fields": len(normalized), + "edits": len(edits), + "resized_collections": len(resized_collections), + }, + } + + +# Compatibility aliases retained by adapter_1c_server and existing clients. +config_schedule_datetime = schedule_datetime +scheduled_job_schedule_layout = schedule_layout +scheduled_job_sql_schedule = decode_schedule +scheduled_job_schedule_rebuild_tree = rebuild_schedule_tree +scheduled_job_schedule_write_edits = schedule_write_edits +SCHEDULED_JOB_WRITABLE_SCALAR_FIELDS = WRITABLE_SCALAR_FIELDS +SCHEDULED_JOB_WRITABLE_LIST_FIELDS = WRITABLE_LIST_FIELDS +SCHEDULED_JOB_INTEGER_RANGES = INTEGER_RANGES + + +__all__ = [ + "INTEGER_RANGES", + "WRITABLE_LIST_FIELDS", + "WRITABLE_SCALAR_FIELDS", + "config_schedule_datetime", + "decode_schedule", + "rebuild_schedule_tree", + "schedule_datetime", + "schedule_layout", + "schedule_write_edits", + "scheduled_job_schedule_layout", + "scheduled_job_schedule_rebuild_tree", + "scheduled_job_schedule_write_edits", + "scheduled_job_sql_schedule", +] diff --git a/plugins/1c/parser/support_rules.py b/plugins/1c/parser/support_rules.py new file mode 100644 index 0000000..b43d5f9 --- /dev/null +++ b/plugins/1c/parser/support_rules.py @@ -0,0 +1,140 @@ +"""Decoder for exported 1C ``ParentConfigurations.bin`` support rules. + +The SQL representation of these data is platform-private and may differ from +the exported representation. Callers must therefore pass bytes from a +positively identified source; this module deliberately does not discover a +source or infer that missing data means "not on support". +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from pathlib import Path +import re +from typing import Any + +from .payload import parse_brace_text, payload_to_text, scalar + + +GUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I) +OBJECT_RULES = { + 0: "not_editable", + 1: "editable_support_preserved", + 2: "not_supported", +} + + +@dataclass(frozen=True) +class SupportRule: + object_guid: str + rule_code: int + rule: str + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class SupplierSupport: + configuration_guid: str + general_mode_code: int + general_mode: str + version: str + producer: str + name: str + declared_object_count: int + rules: tuple[SupportRule, ...] + + def to_dict(self) -> dict[str, Any]: + result = asdict(self) + result["rules"] = [rule.to_dict() for rule in self.rules] + return result + + +def _root_items(data: bytes, source: str) -> tuple[list[dict[str, Any]], dict[str, Any]]: + decoded = payload_to_text(data) + text = decoded.get("text") + if text is None: + raise ValueError(f"{source}: cannot decode support rules text") + root = parse_brace_text(text) + if not (isinstance(root, dict) and root.get("type") == "list"): + raise ValueError(f"{source}: expected root list") + return list(root.get("items") or []), decoded + + +def _integer(items: list[dict[str, Any]], index: int, source: str, field: str) -> int: + try: + return int(scalar(items[index])) + except (IndexError, TypeError, ValueError) as exc: + raise ValueError(f"{source}: invalid {field} at item {index}") from exc + + +def parse_parent_configurations_bytes( + data: bytes, + *, + source: str = "ParentConfigurations.bin", +) -> dict[str, Any]: + """Decode a positively identified exported support-rules payload.""" + + items, decoded = _root_items(data, source) + if len(items) < 3: + raise ValueError(f"{source}: support rules header is incomplete") + format_marker = _integer(items, 0, source, "format marker") + if format_marker != 6: + raise ValueError(f"{source}: expected format marker 6, got {format_marker}") + supplier_count = _integer(items, 2, source, "supplier count") + if supplier_count < 0: + raise ValueError(f"{source}: supplier count must not be negative") + + suppliers: list[SupplierSupport] = [] + position = 3 + for supplier_index in range(supplier_count): + if position + 6 >= len(items): + raise ValueError(f"{source}: supplier {supplier_index} header is incomplete") + configuration_guid = scalar(items[position]).lower() + if not GUID_RE.fullmatch(configuration_guid): + raise ValueError(f"{source}: supplier {supplier_index} configuration GUID is invalid") + general_code = _integer(items, position + 1, source, "general support mode") + object_count = _integer(items, position + 6, source, "object count") + if object_count < 0: + raise ValueError(f"{source}: supplier {supplier_index} object count must not be negative") + object_position = position + 7 + rules: list[SupportRule] = [] + for object_index in range(object_count): + current = object_position + object_index * 4 + if current + 3 >= len(items): + raise ValueError(f"{source}: supplier {supplier_index} object {object_index} is incomplete") + rule_code = _integer(items, current, source, "object support rule") + object_guid = scalar(items[current + 2]).lower() + if rule_code not in OBJECT_RULES: + raise ValueError(f"{source}: unsupported object rule code {rule_code}") + if not GUID_RE.fullmatch(object_guid): + raise ValueError(f"{source}: supplier {supplier_index} object {object_index} GUID is invalid") + effective_code = 0 if general_code != 0 else rule_code + rules.append(SupportRule(object_guid, effective_code, OBJECT_RULES[effective_code])) + suppliers.append( + SupplierSupport( + configuration_guid=configuration_guid, + general_mode_code=general_code, + general_mode="editable" if general_code == 0 else "locked", + version=scalar(items[position + 3]), + producer=scalar(items[position + 4]), + name=scalar(items[position + 5]), + declared_object_count=object_count, + rules=tuple(rules), + ) + ) + position = object_position + object_count * 4 + 2 + + return { + "source": source, + "compression": decoded["compression"], + "encoding": decoded["encoding"], + "format_marker": format_marker, + "supplier_count": supplier_count, + "suppliers": suppliers, + } + + +def parse_parent_configurations_file(path: Path) -> dict[str, Any]: + return parse_parent_configurations_bytes(path.read_bytes(), source=path.name) diff --git a/scripts/check_1c_connector_standalone.py b/scripts/check_1c_connector_standalone.py index 07c63f9..492e1bd 100644 --- a/scripts/check_1c_connector_standalone.py +++ b/scripts/check_1c_connector_standalone.py @@ -50,6 +50,8 @@ def run_checks() -> dict[str, Any]: PARSER / "__init__.py", PARSER / "payload.py", PARSER / "cas_payload.py", + PARSER / "common_command.py", + PARSER / "scheduled_job.py", ] missing = [str(path.relative_to(ROOT)) for path in required if not path.exists()] require(not missing, f"missing standalone connector files: {missing}", failures) @@ -96,7 +98,35 @@ def run_checks() -> dict[str, Any]: require(openapi.get("openapi") == "3.1.0", "connector OpenAPI must parse as 3.1.0", failures) paths = openapi.get("paths") if isinstance(openapi.get("paths"), dict) else {} require("/health" in paths, "connector OpenAPI must expose /health", failures) + require("/methods" in paths, "connector OpenAPI must expose runtime /methods registry", failures) + require("/rpc" in paths, "connector OpenAPI must expose universal /rpc", failures) require("/metadata/write-plan" in paths, "connector OpenAPI must expose /metadata/write-plan", failures) + schemas = ((openapi.get("components") or {}).get("schemas") or {}) if isinstance(openapi.get("components"), dict) else {} + require("AdapterRpcRequest" in schemas, "connector OpenAPI must define AdapterRpcRequest", failures) + require("AdapterMethodsResponse" in schemas, "connector OpenAPI must define AdapterMethodsResponse", failures) + try: + sys.path.insert(0, str(CONNECTOR)) + sys.path.insert(0, str(CONNECTOR.parent)) + import adapter_1c_server as adapter_server + + runtime_paths = set(adapter_server.HTTP_GET_METHOD_ROUTES) | set(adapter_server.HTTP_POST_METHOD_ROUTES) | {"/rpc"} + require( + set(paths) == runtime_paths, + f"OpenAPI/runtime HTTP route drift: missing_in_openapi={sorted(runtime_paths - set(paths))}, missing_in_runtime={sorted(set(paths) - runtime_paths)}", + failures, + ) + require( + all("get" in (paths.get(path) or {}) for path in adapter_server.HTTP_GET_METHOD_ROUTES), + "every runtime GET route must be declared as GET in OpenAPI", + failures, + ) + require( + all("post" in (paths.get(path) or {}) for path in adapter_server.HTTP_POST_METHOD_ROUTES), + "every runtime POST route must be declared as POST in OpenAPI", + failures, + ) + except Exception as exc: + failures.append(f"could not verify runtime HTTP route registry: {exc}") return { "schema": "onec_connector_standalone_check.v1", diff --git a/scripts/check_1c_extension_action_contract.py b/scripts/check_1c_extension_action_contract.py index 63f1ba9..fdf1085 100644 --- a/scripts/check_1c_extension_action_contract.py +++ b/scripts/check_1c_extension_action_contract.py @@ -92,13 +92,14 @@ def run_saved_state_search_with_object_name(params: dict[str, Any] | None = None adapter_server.read_storage_file_bytes = lambda base_id, table, file_name, timeout_seconds=30: (stored, {"database": base_id}, None) request_payload = { "base_id": "upo_test", - "tables": ["ConfigCASSave"], + "layer": "extension_saved_state", "object_type": "Catalog", "object_name": "Номенклатура", "query": "ПередЗаписью", "limit": 10, } request_payload.update(params or {}) + request_payload["include_storage"] = True return adapter_server.call_method( adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD, request_payload, @@ -126,7 +127,9 @@ def run_checks() -> dict[str, Any]: require((unknown_evidence.get("target") or {}).get("extension_action") == unknown_action, "unknown action must be carried into write_plan_evidence target", failures) require("intent" not in unknown_evidence, "unknown action must not infer write intent", failures) require(unknown_next.get("method") == adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD, "write_plan_evidence must expose saved-state module resolver", failures) - require(unknown_next_params.get("tables") == ["ConfigCASSave"], "extension write_plan_evidence must search ConfigCASSave", failures) + require(unknown_next_params.get("layer") == "extension_saved_state", "extension write_plan_evidence must use extension_saved_state", failures) + require(unknown_next_params.get("ref") == "Catalog.Номенклатура", "extension write_plan_evidence must use a public object ref", failures) + require("tables" not in unknown_next_params and "owner_guid" not in unknown_next_params, "public extension evidence must hide storage selectors", failures) controlled = run_override({"operation_class": "replace_with_control"}) controlled_action = first_action(controlled) @@ -152,7 +155,8 @@ def run_checks() -> dict[str, Any]: base_next_params = base_next.get("params") if isinstance(base_next.get("params"), dict) else {} require(base_action.get("operation_class") == "base_definition", "base routine must be marked as base_definition", failures) require(base_action.get("requires_control_fragment") is False, "base routine must not require control fragment", failures) - require(base_next_params.get("tables") == ["ConfigSave"], "base write_plan_evidence must search ConfigSave", failures) + require(base_next_params.get("layer") == "base_saved_state", "base write_plan_evidence must use base_saved_state", failures) + require(base_next_params.get("ref") == "Catalog.Номенклатура", "base write_plan_evidence must use a public object ref", failures) saved_state = run_saved_state_search_with_object_name(controlled_next_params) saved_state_owner = saved_state.get("owner_resolution") if isinstance(saved_state.get("owner_resolution"), dict) else {} diff --git a/scripts/check_1c_mcp_adapter_contract.py b/scripts/check_1c_mcp_adapter_contract.py index f4d2b82..8f05e10 100644 --- a/scripts/check_1c_mcp_adapter_contract.py +++ b/scripts/check_1c_mcp_adapter_contract.py @@ -87,6 +87,7 @@ def contract_checks(issues: list[dict[str, Any]], *, onec_request_present: bool) for issue in issues ), "mcp_tool_selector_guidance": not any(issue["code"] in {"mcp_tool_selector_guidance_missing", "mcp_examples_public_ref_placeholder_missing"} for issue in issues), + "mcp_saved_state_examples_name_first": not any(issue["code"].startswith("mcp_saved_state_example_") for issue in issues), "all_adapter_methods_forward_over_rpc": not any(issue["code"].startswith("mcp_method") or issue["code"] == "mcp_rpc_body_method_mismatch" for issue in issues), "no_unified_shadowing_adapter_methods": not any(issue["code"] == "mcp_unified_shadows_adapter_methods" for issue in issues), "adapter_help_selector_guidance": not any(issue["code"] == "adapter_help_selector_guidance_missing" for issue in issues), @@ -103,6 +104,7 @@ def contract_checks(issues: list[dict[str, Any]], *, onec_request_present: bool) ), "adapter_definition_read_selector_public_ref": not any(issue["code"] == "adapter_definition_read_selector_public_ref_missing" for issue in issues), "adapter_module_read_selector_public_ref": not any(issue["code"] == "adapter_module_read_selector_public_ref_missing" for issue in issues), + "agent_facing_object_methods_name_first": not any(issue["code"] == "agent_facing_object_method_not_name_first" for issue in issues), "adapter_parse_ordinal_unpacked": not any(issue["code"] == "adapter_parse_ordinal_not_unpacked" for issue in issues), "adapter_contract_version": not any( issue["code"] in {"adapter_contract_version_missing", "mcp_contract_version_mismatch", "adapter_help_contract_version_missing"} @@ -393,6 +395,23 @@ def check_contract() -> dict[str, Any]: "actual": help_result.get("contract_version"), } ) + agent_facing_object_terms = ("object", "module", "form", "template", "code") + for row in adapter_server.METHODS: + method = str(row.get("name") or "") + description = str(row.get("description") or "") + normalized_description = description.casefold() + if "agent-facing" not in normalized_description: + continue + if not any(term in normalized_description for term in agent_facing_object_terms): + continue + if method not in selector_alias_methods: + issues.append( + { + "code": "agent_facing_object_method_not_name_first", + "method": method, + "description": description, + } + ) for method in sorted(getattr(adapter_server, "OBJECT_SELECTOR_ALIAS_METHODS", set())): capabilities = (public_methods.get(method) or {}).get("selector_capabilities") if not isinstance(capabilities, dict) or not capabilities.get("accepts_ref") or not capabilities.get("accepts_object_aliases"): @@ -448,6 +467,28 @@ def check_contract() -> dict[str, Any]: examples_json = json.dumps(schema.get("examples") or [], ensure_ascii=False) if "." not in examples_json: issues.append({"code": "mcp_examples_public_ref_placeholder_missing"}) + examples_by_method = { + str(example.get("method") or ""): example.get("payload") + for example in schema.get("examples") or [] + if isinstance(example, dict) and isinstance(example.get("payload"), dict) + } + saved_state_example_contract = { + "metadata.saved_state.prepare": {"required": {"layer", "ref"}, "forbidden": {"target_table", "table", "file_name", "module_ref"}}, + "metadata.saved_state.diff": {"required": {"ref", "module_ordinal"}, "forbidden": {"target_table", "table", "file_name", "module_ref"}}, + "metadata.saved_state.status": {"required": {"layer"}, "forbidden": {"target_table", "table"}}, + "metadata.saved_state.changes.list": {"required": {"layer"}, "forbidden": {"target_table", "table"}}, + } + for method, requirements in saved_state_example_contract.items(): + example_payload = examples_by_method.get(method) + if not isinstance(example_payload, dict): + issues.append({"code": "mcp_saved_state_example_missing", "method": method}) + continue + missing = sorted(requirements["required"] - set(example_payload)) + forbidden = sorted(requirements["forbidden"].intersection(example_payload)) + if missing: + issues.append({"code": "mcp_saved_state_example_public_selector_missing", "method": method, "fields": missing}) + if forbidden: + issues.append({"code": "mcp_saved_state_example_storage_selector_present", "method": method, "fields": forbidden}) if schema.get("required") != ["method"]: issues.append({"code": "mcp_onec_request_required_not_generic", "required": schema.get("required")}) if "oneOf" in schema: diff --git a/scripts/check_1c_metadata_kind_fixtures.py b/scripts/check_1c_metadata_kind_fixtures.py new file mode 100644 index 0000000..4cb3323 --- /dev/null +++ b/scripts/check_1c_metadata_kind_fixtures.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python3 +"""Validate and optionally probe rare 1C metadata-kind fixtures.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable + + +SCHEMA = "onec_metadata_kind_fixture_manifest.v1" +REPORT_SCHEMA = "onec_metadata_kind_fixture_check.v1" +SUPPORTED_MODES = {"extension_saved_state", "dedicated_base_active"} +REFERENCE_USAGE = "external_reference_only_not_vendored" +DESIGNER_ACCESS_MODE = "operating_system_integrated_only" +SECRET_KEY_RE = re.compile(r"(password|passwd|pwd|secret|token|парол|секрет)", re.IGNORECASE) +Rpc = Callable[[str, str, dict[str, Any], float, str], dict[str, Any]] + + +def load_json(path: Path) -> dict[str, Any]: + value = json.loads(path.read_text(encoding="utf-8-sig")) + if not isinstance(value, dict): + raise ValueError("manifest root must be a JSON object") + return value + + +def write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(path.name + ".tmp") + temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + temporary.replace(path) + + +def rpc(adapter_url: str, method: str, payload: dict[str, Any], timeout: float, service_token: str) -> dict[str, Any]: + headers = {"Content-Type": "application/json; charset=utf-8"} + if service_token: + headers["Authorization"] = f"Bearer {service_token}" + request = urllib.request.Request( + adapter_url.rstrip("/") + "/rpc", + data=json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8"), + headers=headers, + method="POST", + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + value = json.loads(response.read().decode("utf-8")) + return value if isinstance(value, dict) else {"status": "error", "error": "response_not_object"} + + +def finding(code: str, message: str, *, fixture_id: str | None = None) -> dict[str, Any]: + value = {"severity": "error", "code": code, "message": message} + if fixture_id: + value["fixture_id"] = fixture_id + return value + + +def secret_key_paths(value: Any, prefix: str = "") -> list[str]: + hits: list[str] = [] + if isinstance(value, dict): + for key, nested in value.items(): + path = f"{prefix}.{key}" if prefix else str(key) + if SECRET_KEY_RE.search(str(key)): + hits.append(path) + hits.extend(secret_key_paths(nested, path)) + elif isinstance(value, list): + for index, nested in enumerate(value): + hits.extend(secret_key_paths(nested, f"{prefix}[{index}]")) + return hits + + +def validate_manifest(manifest: dict[str, Any]) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + if manifest.get("schema") != SCHEMA: + findings.append(finding("invalid_schema", f"schema must be {SCHEMA}")) + + policy = manifest.get("policy") + if not isinstance(policy, dict): + findings.append(finding("missing_policy", "policy must be an object")) + else: + if policy.get("provisioning_tool") != "1c_designer": + findings.append(finding("unsafe_provisioning_tool", "fixtures must be provisioned by 1C Designer")) + if policy.get("direct_sql_write_allowed") is not False: + findings.append(finding("direct_sql_write_not_forbidden", "direct SQL fixture writes must be forbidden")) + if policy.get("credentials_in_manifest_allowed") is not False: + findings.append(finding("credentials_not_forbidden", "credentials must be forbidden in the manifest")) + + for path in secret_key_paths(manifest): + findings.append(finding("credential_key_in_manifest", f"credential-like key is forbidden: {path}")) + + designer = manifest.get("designer") + if not isinstance(designer, dict): + findings.append(finding("missing_designer_requirements", "designer requirements must be an object")) + designer = {} + required_version = str(designer.get("required_version") or "") + if not re.fullmatch(r"\d+\.\d+\.\d+\.\d+", required_version): + findings.append(finding("invalid_designer_version", "designer.required_version must be an exact four-part version")) + if designer.get("access_mode") != DESIGNER_ACCESS_MODE: + findings.append(finding("unsafe_designer_access_mode", f"designer.access_mode must be {DESIGNER_ACCESS_MODE}")) + if designer.get("credential_arguments_allowed") is not False: + findings.append(finding("designer_credential_arguments_not_forbidden", "Designer credential arguments must be forbidden")) + export_script = str(designer.get("export_script") or "") + if not export_script.endswith(".ps1") or Path(export_script).is_absolute(): + findings.append(finding("invalid_designer_export_script", "designer.export_script must be a repository-relative PowerShell path")) + + external_reference = manifest.get("external_reference") + if not isinstance(external_reference, dict): + findings.append(finding("missing_external_reference", "external_reference must be an object")) + external_reference = {} + if external_reference.get("usage") != REFERENCE_USAGE: + findings.append(finding("unsafe_external_reference_usage", f"external_reference.usage must be {REFERENCE_USAGE}")) + if not str(external_reference.get("repository") or "").startswith("https://"): + findings.append(finding("invalid_external_reference_repository", "external_reference.repository must be an HTTPS URL")) + if not re.fullmatch(r"[0-9a-f]{40}", str(external_reference.get("commit") or "")): + findings.append(finding("invalid_external_reference_commit", "external_reference.commit must be a full Git commit")) + for key in ("source_path", "license"): + if not str(external_reference.get(key) or "").strip(): + findings.append(finding("incomplete_external_reference", f"external_reference.{key} is required")) + + required_kinds = manifest.get("required_kinds") + if not isinstance(required_kinds, list) or not required_kinds or any(not isinstance(item, str) or not item for item in required_kinds): + findings.append(finding("invalid_required_kinds", "required_kinds must be a non-empty string array")) + required_kinds = [] + + fixtures = manifest.get("fixtures") + if not isinstance(fixtures, list): + findings.append(finding("invalid_fixtures", "fixtures must be an array")) + return findings + + seen_ids: set[str] = set() + seen_kinds: set[str] = set() + for fixture in fixtures: + if not isinstance(fixture, dict): + findings.append(finding("invalid_fixture", "each fixture must be an object")) + continue + fixture_id = str(fixture.get("id") or "") + kind = str(fixture.get("kind") or "") + if not fixture_id: + findings.append(finding("missing_fixture_id", "fixture id is required")) + elif fixture_id in seen_ids: + findings.append(finding("duplicate_fixture_id", f"duplicate fixture id: {fixture_id}", fixture_id=fixture_id)) + seen_ids.add(fixture_id) + if not kind: + findings.append(finding("missing_fixture_kind", "fixture kind is required", fixture_id=fixture_id)) + elif kind in seen_kinds: + findings.append(finding("duplicate_fixture_kind", f"duplicate fixture kind: {kind}", fixture_id=fixture_id)) + seen_kinds.add(kind) + + target = fixture.get("target") + mode = target.get("mode") if isinstance(target, dict) else None + if mode not in SUPPORTED_MODES: + findings.append(finding("invalid_target_mode", f"unsupported target mode: {mode}", fixture_id=fixture_id)) + if mode == "extension_saved_state": + for key in ("base_id", "extension", "state"): + if not isinstance(target.get(key), str) or not target.get(key): + findings.append(finding("missing_extension_target", f"target.{key} is required", fixture_id=fixture_id)) + if required_version and target.get("platform_version") != required_version: + findings.append( + finding( + "fixture_designer_version_mismatch", + f"target.platform_version must equal designer.required_version ({required_version})", + fixture_id=fixture_id, + ) + ) + if mode == "dedicated_base_active" and target.get("base_override_required") is not True: + findings.append(finding("unsafe_shared_legacy_target", "dedicated legacy fixture must require an explicit base override", fixture_id=fixture_id)) + + selector = fixture.get("selector") + names = selector.get("names") if isinstance(selector, dict) else None + if not isinstance(names, list) or not names or any(not isinstance(name, str) or not name.strip() for name in names): + findings.append(finding("invalid_selector_names", "selector.names must be a non-empty string array", fixture_id=fixture_id)) + methods = fixture.get("smoke_methods") + if not isinstance(methods, list) or not methods or any(not isinstance(method, str) or not method for method in methods): + findings.append(finding("invalid_smoke_methods", "smoke_methods must be a non-empty string array", fixture_id=fixture_id)) + + missing = sorted(set(required_kinds) - seen_kinds) + extra = sorted(seen_kinds - set(required_kinds)) + if missing: + findings.append(finding("required_fixture_missing", f"fixtures missing for kinds: {', '.join(missing)}")) + if extra: + findings.append(finding("unexpected_fixture_kind", f"unexpected fixture kinds: {', '.join(extra)}")) + kind_paths = external_reference.get("kind_paths") + if not isinstance(kind_paths, dict): + findings.append(finding("missing_external_reference_kind_paths", "external_reference.kind_paths must be an object")) + else: + missing_reference_kinds = sorted(set(required_kinds) - set(kind_paths)) + if missing_reference_kinds: + findings.append( + finding( + "external_reference_kind_missing", + f"external reference paths missing for kinds: {', '.join(missing_reference_kinds)}", + ) + ) + return findings + + +def normalize_guid(value: Any) -> str: + return str(value or "").strip().strip("{}").casefold() + + +def matches_selector(obj: dict[str, Any], selector: dict[str, Any]) -> bool: + names = {str(value).strip().casefold() for value in selector.get("names") or []} + guid = normalize_guid(selector.get("guid")) + object_name = str(obj.get("name") or "").strip().casefold() + object_guid = normalize_guid(obj.get("guid")) + if guid and object_guid == guid: + return True + return bool(object_name and object_name in names) + + +def object_public_selector(base_id: str, fixture: dict[str, Any], obj: dict[str, Any]) -> dict[str, Any]: + selector: dict[str, Any] = {"base_id": base_id, "kind": fixture["kind"]} + if obj.get("name"): + selector["name"] = obj["name"] + if obj.get("guid"): + selector["guid"] = obj["guid"] + target = fixture.get("target") or {} + route = obj.get("route") if isinstance(obj.get("route"), dict) else {} + extension_guid = obj.get("extension_guid") or route.get("extension_guid") + if extension_guid: + selector["extension_guid"] = extension_guid + elif target.get("extension"): + selector["extension"] = target["extension"] + return selector + + +def probe_smoke_methods( + adapter_url: str, + service_token: str, + timeout: float, + fixture: dict[str, Any], + base_id: str, + obj: dict[str, Any], + rpc_call: Rpc, +) -> list[dict[str, Any]]: + selector = object_public_selector(base_id, fixture, obj) + results: list[dict[str, Any]] = [] + for method in fixture.get("smoke_methods") or []: + payload = {**selector, "timeout_seconds": max(1, int(timeout))} + if method == "metadata.object.attributes": + payload["only"] = "all" + try: + response = rpc_call(adapter_url, method, payload, timeout, service_token) + status = str(response.get("status") or "unknown") + results.append({"method": method, "status": status, **({"error": response.get("error")} if response.get("error") else {})}) + except (TimeoutError, urllib.error.URLError, OSError, ValueError, json.JSONDecodeError) as exc: + results.append({"method": method, "status": "transport_error", "error": type(exc).__name__, "message": str(exc)[:300]}) + return results + + +def check_fixture_live( + fixture: dict[str, Any], + adapter_url: str, + service_token: str, + timeout: float, + base_overrides: dict[str, str], + rpc_call: Rpc, + probe_cache: dict[tuple[str, str, str, str], dict[str, Any]] | None = None, +) -> dict[str, Any]: + fixture_id = str(fixture["id"]) + target = fixture.get("target") or {} + base_id = base_overrides.get(fixture_id) or target.get("base_id") + if target.get("base_override_required") and fixture_id not in base_overrides: + return { + "id": fixture_id, + "kind": fixture["kind"], + "status": "target_not_configured", + "message": f"Pass --target-base {fixture_id}=BASE_ID for the dedicated legacy fixture.", + } + if not base_id: + return {"id": fixture_id, "kind": fixture["kind"], "status": "target_not_configured"} + + payload: dict[str, Any] + method: str + if target.get("mode") == "extension_saved_state": + method = "extension.objects.find" + payload = { + "base_id": base_id, + "extension": target["extension"], + "state": target.get("state") or "working", + "limit": 100, + "use_cache": True, + "full_scan": False, + "refresh_cache": False, + "timeout_seconds": max(1, int(timeout)), + } + else: + method = "metadata.objects.list" + payload = { + "base_id": base_id, + "kind": fixture["kind"], + "limit": 100, + "refresh_cache": True, + "timeout_seconds": max(1, int(timeout)), + } + + cache_key = ( + method, + str(base_id), + str(target.get("extension") or ""), + str(target.get("state") or ""), + ) + cached_probe_error: dict[str, Any] | None = None + try: + if probe_cache is not None and cache_key in probe_cache: + cached = probe_cache[cache_key] + cached_probe_error = cached.get("__probe_error__") if isinstance(cached.get("__probe_error__"), dict) else None + if cached_probe_error is not None: + raise TimeoutError(str(cached_probe_error.get("message") or "cached adapter probe failed")) + response = cached + else: + response = rpc_call(adapter_url, method, payload, timeout, service_token) + if probe_cache is not None: + probe_cache[cache_key] = response + except (TimeoutError, urllib.error.URLError, OSError, ValueError, json.JSONDecodeError) as exc: + error_name = str(cached_probe_error.get("error")) if cached_probe_error else type(exc).__name__ + error_message = str(cached_probe_error.get("message")) if cached_probe_error else str(exc)[:300] + if probe_cache is not None and cached_probe_error is None: + probe_cache[cache_key] = { + "__probe_error__": { + "error": error_name, + "message": error_message, + } + } + return { + "id": fixture_id, + "kind": fixture["kind"], + "base_id": base_id, + "status": "adapter_error", + "probe_method": method, + "error": error_name, + "message": error_message, + **({"probe_error_reused": True} if cached_probe_error is not None else {}), + } + + objects = response.get("objects") if isinstance(response.get("objects"), list) else response.get("items") + objects = [obj for obj in (objects or []) if isinstance(obj, dict)] + kind_objects = [obj for obj in objects if str(obj.get("kind") or "") == str(fixture["kind"])] + matched = next((obj for obj in kind_objects if matches_selector(obj, fixture.get("selector") or {})), None) + if not matched: + return { + "id": fixture_id, + "kind": fixture["kind"], + "base_id": base_id, + "status": "fixture_missing" if response.get("status") in {"ok", "not_found"} else "adapter_error", + "probe_method": method, + "adapter_status": response.get("status"), + "observed_names": sorted({str(obj.get("name")) for obj in kind_objects if obj.get("name")})[:20], + } + + expected_states = set(fixture.get("expected_activation_states") or []) + activation_state = str(matched.get("activation_state") or "active") + smoke = probe_smoke_methods(adapter_url, service_token, timeout, fixture, base_id, matched, rpc_call) + failed_smoke = [item["method"] for item in smoke if item.get("status") != "ok"] + state_ok = not expected_states or activation_state in expected_states + return { + "id": fixture_id, + "kind": fixture["kind"], + "base_id": base_id, + "status": "fixture_ready" if state_ok and not failed_smoke else "fixture_degraded", + "probe_method": method, + "adapter_status": response.get("status"), + "object": { + "name": matched.get("name"), + "guid": matched.get("guid"), + "activation_state": activation_state, + }, + "smoke": smoke, + **({"failed_smoke_methods": failed_smoke} if failed_smoke else {}), + **({"unexpected_activation_state": activation_state} if not state_ok else {}), + } + + +def parse_base_overrides(values: list[str]) -> dict[str, str]: + result: dict[str, str] = {} + for value in values: + fixture_id, separator, base_id = value.partition("=") + if not separator or not fixture_id.strip() or not base_id.strip(): + raise ValueError(f"invalid --target-base value: {value}; expected FIXTURE_ID=BASE_ID") + result[fixture_id.strip()] = base_id.strip() + return result + + +def check_manifest( + manifest_path: Path, + *, + live: bool = False, + adapter_url: str = "http://docker-gpu.cin.su:8011", + service_token: str = "", + timeout: float = 90, + base_overrides: dict[str, str] | None = None, + require_ready: bool = False, + rpc_call: Rpc = rpc, +) -> dict[str, Any]: + try: + manifest = load_json(manifest_path) + except (OSError, ValueError, json.JSONDecodeError) as exc: + findings = [finding("manifest_read_failed", str(exc))] + manifest = {} + else: + findings = validate_manifest(manifest) + + fixture_results: list[dict[str, Any]] = [] + if not findings: + if live: + probe_cache: dict[tuple[str, str, str, str], dict[str, Any]] = {} + fixture_results = [ + check_fixture_live( + fixture, + adapter_url, + service_token, + timeout, + base_overrides or {}, + rpc_call, + probe_cache, + ) + for fixture in manifest.get("fixtures") or [] + ] + else: + fixture_results = [ + {"id": fixture["id"], "kind": fixture["kind"], "status": "not_checked"} + for fixture in manifest.get("fixtures") or [] + ] + + ready = len([item for item in fixture_results if item.get("status") == "fixture_ready"]) + gaps = len([item for item in fixture_results if item.get("status") in {"fixture_missing", "fixture_degraded", "target_not_configured", "adapter_error"}]) + passed = not findings and (not require_ready or (bool(fixture_results) and ready == len(fixture_results))) + return { + "schema": REPORT_SCHEMA, + "created_at_utc": datetime.now(timezone.utc).isoformat(), + "status": "invalid_manifest" if findings else ("ready" if fixture_results and ready == len(fixture_results) else ("gaps_found" if live else "manifest_valid")), + "passed": passed, + "manifest_path": str(manifest_path), + "live": live, + "require_ready": require_ready, + "adapter_url": adapter_url if live else None, + "findings": findings, + "fixtures": fixture_results, + "counts": { + "findings": len(findings), + "fixtures": len(fixture_results), + "ready": ready, + "gaps": gaps, + }, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Validate and optionally probe rare 1C metadata-kind fixtures.") + parser.add_argument("--manifest", type=Path, default=Path("config/1c_metadata_kind_fixtures.json")) + parser.add_argument("--live", action="store_true") + parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011") + parser.add_argument("--service-token-env", default="ONEC_ADAPTER_SERVICE_TOKEN") + parser.add_argument("--timeout", type=float, default=90) + parser.add_argument("--target-base", action="append", default=[], metavar="FIXTURE_ID=BASE_ID") + parser.add_argument("--require-ready", action="store_true") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + + try: + base_overrides = parse_base_overrides(args.target_base) + except ValueError as exc: + parser.error(str(exc)) + result = check_manifest( + args.manifest, + live=args.live, + adapter_url=args.adapter_url, + service_token=os.environ.get(args.service_token_env, ""), + timeout=args.timeout, + base_overrides=base_overrides, + require_ready=args.require_ready, + ) + if args.output: + write_json(args.output, result) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if result["passed"] else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_powershell_scripts.py b/scripts/check_powershell_scripts.py index 0f54a67..2050721 100644 --- a/scripts/check_powershell_scripts.py +++ b/scripts/check_powershell_scripts.py @@ -334,6 +334,8 @@ def check_adapter_verify_wiring(scripts: list[Path], executable: str) -> list[st failures.append("deploy_1c_adapter_stack.ps1 must declare -SkipWriteRollbackSafetySmoke.") if "[switch]$SkipSavedStateDiffSmoke" not in deploy_text: failures.append("deploy_1c_adapter_stack.ps1 must declare -SkipSavedStateDiffSmoke.") + if "[switch]$SkipCodeWriteSavedStateSmoke" not in deploy_text: + failures.append("deploy_1c_adapter_stack.ps1 must declare -SkipCodeWriteSavedStateSmoke.") if "[string]$SavedStateTable" not in deploy_text or '[ValidateSet("ConfigSave", "ConfigCASSave")]' not in deploy_text: failures.append("deploy_1c_adapter_stack.ps1 must declare -SavedStateTable with ConfigSave/ConfigCASSave validation.") if '"-RequireSelectorChainWritePlanComposition"' not in deploy_text: @@ -346,6 +348,8 @@ def check_adapter_verify_wiring(scripts: list[Path], executable: str) -> list[st failures.append("deploy_1c_adapter_stack.ps1 must forward -SkipWriteRollbackSafetySmoke to verify_1c_adapter_deployment.ps1.") if '"-SkipSavedStateDiffSmoke"' not in deploy_text: failures.append("deploy_1c_adapter_stack.ps1 must forward -SkipSavedStateDiffSmoke to verify_1c_adapter_deployment.ps1.") + if '"-SkipCodeWriteSavedStateSmoke"' not in deploy_text: + failures.append("deploy_1c_adapter_stack.ps1 must forward -SkipCodeWriteSavedStateSmoke to verify_1c_adapter_deployment.ps1.") if "function Get-DuplicateValues" not in deploy_text or "Duplicate BaseId value(s)" not in deploy_text: failures.append("deploy_1c_adapter_stack.ps1 must reject duplicate -BaseId values before invoking verification.") if "function Normalize-BaseIds" not in deploy_text or '-split ","' not in deploy_text: diff --git a/scripts/deploy_1c_adapter_stack.ps1 b/scripts/deploy_1c_adapter_stack.ps1 index f0e47ca..365d2da 100644 --- a/scripts/deploy_1c_adapter_stack.ps1 +++ b/scripts/deploy_1c_adapter_stack.ps1 @@ -23,6 +23,7 @@ param( [switch]$SkipWriteRollbackSafetySmoke, [switch]$SkipSavedStateDiffSmoke, [switch]$SkipSavedStateWriteSmoke, + [switch]$SkipCodeWriteSavedStateSmoke, [switch]$RequireSavedStateWriteSmoke, [switch]$RequireSelectorChainWritePlanComposition ) @@ -216,6 +217,9 @@ try { if ($SkipSavedStateWriteSmoke) { $verifyCommand += "-SkipSavedStateWriteSmoke" } + if ($SkipCodeWriteSavedStateSmoke) { + $verifyCommand += "-SkipCodeWriteSavedStateSmoke" + } if ($RequireSavedStateWriteSmoke) { $verifyCommand += "-RequireSavedStateWriteSmoke" } diff --git a/scripts/export_1c_extension_sources.ps1 b/scripts/export_1c_extension_sources.ps1 new file mode 100644 index 0000000..ae66071 --- /dev/null +++ b/scripts/export_1c_extension_sources.ps1 @@ -0,0 +1,75 @@ +[CmdletBinding()] +param( + [string]$DesignerPath = "C:\Program Files\1cv8\8.5.1.1236\bin\1cv8.exe", + [string]$ExpectedVersion = "8.5.1.1236", + [string]$ServerConnection = "wsr\upo_test", + [string]$Extension = "test2", + [Parameter(Mandatory = $true)] + [string]$OutputDirectory +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +if (-not (Test-Path -LiteralPath $DesignerPath -PathType Leaf)) { + throw "1C Designer executable not found: $DesignerPath" +} + +$actualVersion = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($DesignerPath).FileVersion +if ($actualVersion -ne $ExpectedVersion) { + throw "1C Designer version mismatch: expected $ExpectedVersion, found $actualVersion" +} + +$resolvedOutput = [System.IO.Path]::GetFullPath($OutputDirectory) +if (Test-Path -LiteralPath $resolvedOutput) { + $existingItems = @(Get-ChildItem -LiteralPath $resolvedOutput -Force) + if ($existingItems.Count -gt 0) { + throw "Output directory must be empty: $resolvedOutput" + } +} +else { + New-Item -ItemType Directory -Path $resolvedOutput | Out-Null +} + +$logPath = [System.IO.Path]::GetTempFileName() +try { + $designerArguments = @( + "DESIGNER", + "/S", $ServerConnection, + "/WA+", + "/DisableStartupMessages", + "/DisableStartupDialogs", + "/Out", $logPath, + "/DumpConfigToFiles", $resolvedOutput, + "-Extension", $Extension + ) + + & $DesignerPath @designerArguments + $designerExitCode = $LASTEXITCODE + $designerLog = Get-Content -LiteralPath $logPath -Raw -ErrorAction SilentlyContinue + if ($designerExitCode -ne 0) { + $details = if ($designerLog) { $designerLog.Trim() } else { "Designer did not write a diagnostic log." } + throw "1C Designer export failed with exit code ${designerExitCode}: $details" + } + + $exportedFiles = @(Get-ChildItem -LiteralPath $resolvedOutput -File -Recurse) + if ($exportedFiles.Count -eq 0) { + $details = if ($designerLog) { $designerLog.Trim() } else { "No diagnostic log was written." } + throw "1C Designer reported success but exported no files: $details" + } + + [pscustomobject]@{ + status = "exported" + designer_version = $actualVersion + server_connection = $ServerConnection + extension = $Extension + output_directory = $resolvedOutput + exported_files = $exportedFiles.Count + access_mode = "operating_system_integrated" + } | ConvertTo-Json -Depth 3 +} +finally { + if (Test-Path -LiteralPath $logPath -PathType Leaf) { + Remove-Item -LiteralPath $logPath -Force + } +} diff --git a/scripts/smoke_1c_saved_state_changes.py b/scripts/smoke_1c_saved_state_changes.py index 6e21b69..e81f5a1 100644 --- a/scripts/smoke_1c_saved_state_changes.py +++ b/scripts/smoke_1c_saved_state_changes.py @@ -138,7 +138,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str changes = rpc_call( endpoint_url, "metadata.saved_state.changes.list", - {"base_id": base_id, "limit": 50, "timeout_seconds": int(timeout)}, + {"base_id": base_id, "limit": 50, "timeout_seconds": int(timeout), "include_storage": True}, timeout, transport=transport, session_id=session_id, @@ -169,7 +169,15 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str contextual = rpc_call( endpoint_url, "metadata.saved_state.changes.list", - {"base_id": base_id, "limit": 10, "include_context": True, "group_by_context": True, "context_limit": 5, "timeout_seconds": int(timeout)}, + { + "base_id": base_id, + "limit": 10, + "include_context": True, + "group_by_context": True, + "context_limit": 5, + "timeout_seconds": int(timeout), + "include_storage": True, + }, timeout, transport=transport, session_id=session_id, diff --git a/scripts/smoke_1c_saved_state_diff.py b/scripts/smoke_1c_saved_state_diff.py index 5f6df96..13192f2 100644 --- a/scripts/smoke_1c_saved_state_diff.py +++ b/scripts/smoke_1c_saved_state_diff.py @@ -120,7 +120,14 @@ def first_saved_form(endpoint_url: str, base_id: str, timeout: float, *, transpo result = rpc_call( endpoint_url, "metadata.saved_state.forms.search", - {"base_id": base_id, "tables": [table], "limit": 1, "scan_limit": 200, "timeout_seconds": int(timeout)}, + { + "base_id": base_id, + "tables": [table], + "limit": 1, + "scan_limit": 200, + "timeout_seconds": int(timeout), + "include_storage": True, + }, timeout, transport=transport, session_id=session_id, @@ -171,6 +178,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str "max_changes": 20, "max_text_diff_lines": 20, "timeout_seconds": int(timeout), + "include_storage": True, } diff = rpc_call(endpoint_url, "metadata.saved_state.diff", diff_payload, timeout, transport=transport, session_id=session_id) checks["diff_existing_saved_state"] = { @@ -193,7 +201,13 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str diff = rpc_call( endpoint_url, "metadata.saved_state.diff", - {"base_id": base_id, "table": saved_state_table, "file_name": missing_file, "timeout_seconds": int(timeout)}, + { + "base_id": base_id, + "table": saved_state_table, + "file_name": missing_file, + "timeout_seconds": int(timeout), + "include_storage": True, + }, timeout, transport=transport, session_id=session_id, diff --git a/scripts/smoke_1c_write_plan_safety.py b/scripts/smoke_1c_write_plan_safety.py index 2175f5e..f467d95 100644 --- a/scripts/smoke_1c_write_plan_safety.py +++ b/scripts/smoke_1c_write_plan_safety.py @@ -174,7 +174,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str require(diagnostic_fallback.get("status") == "blocked", "MCP must block low-level diagnostic fallback methods", failures) require(diagnostic_fallback.get("reason") == "diagnostic_method", "MCP diagnostic fallback block must use diagnostic_method reason", failures) - blocked_effective = rpc_call( + blocked_effective_apply = rpc_call( endpoint_url, "metadata.write", { @@ -189,11 +189,37 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str transport=transport, session_id=session_id, ) + apply_gate = None + if blocked_effective_apply.get("schema") == "onec_repository_write_gate.v1": + apply_gate = { + "status": blocked_effective_apply.get("status"), + "error": blocked_effective_apply.get("error"), + } + # Repository/support coordination is allowed to block apply before the + # metadata route is evaluated. Repeat in non-mutating plan mode so the + # smoke still verifies the effective-path routing contract. + blocked_effective = rpc_call( + endpoint_url, + "metadata.write", + { + "base_id": base_id, + "target": {"canonical_path": "Документ.АвансовыйОтчет.Форма.ФормаДокумента.КнопкаЗаписать"}, + "mode": "plan", + "edits": [{"property": "Заголовок", "value": "BLOCKED_SMOKE"}], + "timeout_seconds": int(timeout), + }, + timeout, + transport=transport, + session_id=session_id, + ) + else: + blocked_effective = blocked_effective_apply checks["blocked_effective_form_path"] = { "status": blocked_effective.get("status"), "error": blocked_effective.get("error"), "routed_method": blocked_effective.get("routed_method"), "target_kind": blocked_effective.get("target_kind"), + **({"apply_gate": apply_gate} if apply_gate else {}), } require(blocked_effective.get("status") == "blocked", "effective canonical path write must be blocked", failures) require(blocked_effective.get("error") == "write_plan_required", "effective canonical path write must require write plan", failures) diff --git a/scripts/smoke_1c_write_preflight.py b/scripts/smoke_1c_write_preflight.py index 5185cee..aec4bdd 100644 --- a/scripts/smoke_1c_write_preflight.py +++ b/scripts/smoke_1c_write_preflight.py @@ -12,6 +12,17 @@ from urllib.error import HTTPError, URLError DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" DEFAULT_MCP_URL = "http://docker.cin.su:8021" EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1" +PREFLIGHT_CLASSIFICATION_STATUSES = { + "ready", + "needs_prepare", + "needs_resolution", + "needs_repository_lock", + "blocked", + "blocked_by_support", + "blocked_by_support_live_sql", + "blocked_by_support_rule", + "blocked_support_unknown", +} def post_json(url: str, payload: dict[str, Any], *, timeout: float, headers: dict[str, str] | None = None) -> tuple[dict[str, str], dict[str, Any]]: @@ -106,6 +117,10 @@ def require(condition: bool, message: str, failures: list[str]) -> None: failures.append(message) +def classified_preflight_status(value: Any) -> bool: + return str(value or "") in PREFLIGHT_CLASSIFICATION_STATUSES + + def method_names(help_result: dict[str, Any]) -> set[str]: return {str(item.get("name") or "") for item in help_result.get("methods") or [] if isinstance(item, dict)} @@ -120,13 +135,24 @@ def first_saved_module_target(endpoint_url: str, base_id: str, timeout: float, * session_id=session_id, ) modules = result.get("modules") if isinstance(result.get("modules"), list) else [] + targets: list[dict[str, Any]] = [] for module in modules: if not isinstance(module, dict): continue for stream in module.get("streams") or []: if isinstance(stream, dict) and isinstance(stream.get("write_plan_target"), dict): - return stream["write_plan_target"] - return None + targets.append(stream["write_plan_target"]) + # A concrete stream target can be freshness-checked without decoding and + # structurally diffing a whole saved form descriptor. Prefer it so this + # deployment smoke remains bounded on large ConfigCASSave forms. + return next( + ( + target + for target in targets + if target.get("stream_index") is not None or "#stream:" in str(target.get("module_ref") or "") + ), + targets[0] if targets else None, + ) def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str) -> dict[str, Any]: @@ -173,7 +199,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str "plan_allowed": (blocked.get("plan") or {}).get("allowed") if isinstance(blocked.get("plan"), dict) else None, } require(blocked.get("schema") == "onec_metadata_write_preflight.v1", "preflight must return expected schema", failures) - require(blocked.get("status") in {"blocked", "needs_resolution"}, "effective path preflight must be blocked or need resolution", failures) + require(classified_preflight_status(blocked.get("status")), "effective path preflight must classify readiness or a safety gate", failures) require(blocked.get("allowed") is False, "effective path preflight must not be allowed", failures) target = first_saved_module_target(endpoint_url, base_id, timeout, transport=transport, session_id=session_id) @@ -201,7 +227,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str "writer": (concrete.get("route") or {}).get("writer") if isinstance(concrete.get("route"), dict) else None, } require(concrete.get("schema") == "onec_metadata_write_preflight.v1", "concrete preflight must return expected schema", failures) - require(concrete.get("status") in {"ready", "needs_prepare", "blocked"}, "concrete preflight must classify readiness", failures) + require(classified_preflight_status(concrete.get("status")), "concrete preflight must classify readiness or a safety gate", failures) if concrete.get("status") == "ready": require(freshness.get("status") == "live_sql_verified", "ready concrete preflight must be live SQL verified", failures) else: @@ -230,7 +256,18 @@ def main() -> int: args = parser.parse_args() endpoint_url = args.mcp_url if args.transport == "mcp" else args.base_url - report = run_smoke(endpoint_url, args.base_id, args.timeout, transport=args.transport) + try: + report = run_smoke(endpoint_url, args.base_id, args.timeout, transport=args.transport) + except (TimeoutError, URLError, OSError) as exc: + report = { + "schema": "onec_write_preflight_smoke.v1", + "status": "failed", + "endpoint_url": endpoint_url, + "transport": args.transport, + "base_id": args.base_id, + "checks": {}, + "failures": [f"transport timeout/error: {type(exc).__name__}: {str(exc)[:300]}"], + } if args.report: args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") diff --git a/tests/1c/test_adapter_sql_admin.py b/tests/1c/test_adapter_sql_admin.py index f58898f..6aac21f 100644 --- a/tests/1c/test_adapter_sql_admin.py +++ b/tests/1c/test_adapter_sql_admin.py @@ -76,3 +76,54 @@ def test_sql_admin_preserves_repository_user_from_settings() -> None: assert item["repository"]["repository_password_env"] == "REPO_PASSWORD" assert item["repository"]["infobase_user"] == "designer-user" assert item["repository"]["infobase_password_env"] == "IB_PASSWORD" + + +def test_sql_admin_accepts_independent_base_and_extension_policies() -> None: + extension_guid = "11111111-2222-3333-4444-555555555555" + _, item = adapter.sql_admin_validate({ + "base_id": "upo_test", "server": "sql", "database": "upo_test", "user": "reader", + "development_layers": { + "base": {"repository": {"mode": "manual"}, "support": {"mode": "rules", "rules": {"Документ.Заказ": "vendor_not_editable"}}}, + f"extension:{extension_guid}": {"repository": {"mode": "none"}, "support": {"mode": "none"}}, + }, + }) + assert item["development_layers"]["base"]["repository"]["mode"] == "manual" + assert item["development_layers"]["base"]["support"]["rules"]["Документ.Заказ"] == "vendor_not_editable" + assert item["development_layers"][f"extension:{extension_guid}"]["repository"]["mode"] == "none" + + +def test_sql_admin_rejects_extension_layer_without_guid() -> None: + with pytest.raises(ValueError, match="extension:"): + adapter.sql_admin_validate({ + "base_id": "upo_test", "server": "sql", "database": "upo_test", "user": "reader", + "development_layers": {"extension:test": {"repository": {"mode": "none"}, "support": {"mode": "none"}}}, + }) + + +def test_agent_can_persist_layer_repository_connection_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + path = tmp_path / "bases.json" + guid = "11111111-2222-3333-4444-555555555555" + monkeypatch.delenv("ONEC_SQL_BASES_JSON", raising=False) + monkeypatch.setenv("ONEC_SQL_BASES_JSON_FILE", str(path)) + adapter.sql_admin_save({ + "neft": { + "server": "sql", "database": "neft", "user": "reader", + "development_layers": { + "base": {"repository": {"mode": "manual", "connection_state": "configured"}, "support": {"mode": "unknown"}}, + f"extension:{guid}": {"repository": {"mode": "unknown", "connection_state": "unknown"}, "support": {"mode": "unknown"}}, + }, + }, + }) + missing_confirmation = adapter.call_method("repository.layer.connection.set", {"base_id": "neft", "extension_guid": guid, "connection_state": "not_configured"}) + assert missing_confirmation["status"] == "invalid_argument" + result = adapter.call_method("repository.layer.connection.set", { + "base_id": "neft", "extension_guid": guid, "connection_state": "not_configured", "repository_user": "repo-extension-user", + "confirm_repository_connection_change": True, + }) + assert result["status"] == "saved" + assert result["repository_status"] == "repository_not_connected" + stored = adapter.sql_admin_load() + repository = stored["neft"]["development_layers"][f"extension:{guid}"]["repository"] + assert repository["connection_state"] == "not_configured" + assert repository["mode"] == "none" + assert repository["repository_user"] == "repo-extension-user" diff --git a/tests/1c/test_common_command_parser.py b/tests/1c/test_common_command_parser.py new file mode 100644 index 0000000..28a4b56 --- /dev/null +++ b/tests/1c/test_common_command_parser.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "plugins" / "1c")) + +from parser.common_command import ( # noqa: E402 + common_command_group_guid, + index_common_command_groups, + parse_common_command_tree, +) +from parser.payload import serialize_brace_tree # noqa: E402 + + +def command_tree(group_guid: str) -> dict[str, Any]: + atom = lambda value: {"type": "atom", "value": str(value)} + sequence = lambda *items: {"type": "list", "items": list(items)} + root = sequence() + node = root + path = (1, 1, 2, 7, 1) + for index in path[:-1]: + items = node.setdefault("items", []) + while len(items) <= index: + items.append(sequence()) + node = items[index] + items = node.setdefault("items", []) + while len(items) <= path[-1]: + items.append(sequence()) + items[path[-1]] = atom(group_guid) + return root + + +def payload(group_guid: str) -> bytes: + return serialize_brace_tree(command_tree(group_guid)).encode("utf-8") + + +def test_common_command_group_guid_decodes_observed_relation() -> None: + group_guid = "11111111-1111-1111-1111-111111111111" + tree = parse_common_command_tree(payload(group_guid)) + + assert common_command_group_guid(tree) == group_guid + + +def test_common_command_reverse_index_groups_rows_and_reports_gaps() -> None: + group_guid = "11111111-1111-1111-1111-111111111111" + first_guid = "22222222-2222-2222-2222-222222222222" + broken_guid = "33333333-3333-3333-3333-333333333333" + missing_guid = "44444444-4444-4444-4444-444444444444" + commands = [ + {"guid": first_guid, "name": "Первая"}, + {"guid": broken_guid, "name": "Поврежденная"}, + {"guid": missing_guid, "name": "Без файла"}, + ] + + result = index_common_command_groups( + commands, + { + first_guid.upper(): payload(group_guid), + broken_guid: b"not a Config tree", + }, + ) + + assert result["groups"][group_guid] == [{"guid": first_guid, "name": "Первая"}] + assert result["scanned"] == 3 + assert result["source_missing"] == 1 + assert result["undecodable"] == 1 + assert result["unassigned"] == 0 diff --git a/tests/1c/test_deploy_1c_adapter_security.py b/tests/1c/test_deploy_1c_adapter_security.py index 584cd7c..4461a61 100644 --- a/tests/1c/test_deploy_1c_adapter_security.py +++ b/tests/1c/test_deploy_1c_adapter_security.py @@ -13,3 +13,12 @@ def test_rest_deploy_refuses_to_drop_service_authentication() -> None: assert "Reusing the existing REST service token without printing or persisting it" in source assert "Refusing to deploy an unauthenticated adapter" in source assert source.index("Ensure-RestServiceToken") < source.index("Invoke-ComposeUp `", source.index("try {")) + + +def test_deploy_can_forward_complete_saved_state_write_smoke_skip() -> None: + source = (ROOT / "scripts" / "deploy_1c_adapter_stack.ps1").read_text(encoding="utf-8-sig") + + assert "[switch]$SkipSavedStateWriteSmoke" in source + assert "[switch]$SkipCodeWriteSavedStateSmoke" in source + assert '$verifyCommand += "-SkipSavedStateWriteSmoke"' in source + assert '$verifyCommand += "-SkipCodeWriteSavedStateSmoke"' in source diff --git a/tests/1c/test_metadata_kind_fixtures.py b/tests/1c/test_metadata_kind_fixtures.py new file mode 100644 index 0000000..11f40c4 --- /dev/null +++ b/tests/1c/test_metadata_kind_fixtures.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +import scripts.check_1c_metadata_kind_fixtures as fixtures # noqa: E402 + + +MANIFEST = ROOT / "config" / "1c_metadata_kind_fixtures.json" + + +def test_fixture_manifest_is_valid_and_forbids_direct_sql() -> None: + manifest = fixtures.load_json(MANIFEST) + + assert fixtures.validate_manifest(manifest) == [] + assert manifest["policy"]["direct_sql_write_allowed"] is False + assert manifest["designer"] == { + "required_version": "8.5.1.1236", + "access_mode": "operating_system_integrated_only", + "credential_arguments_allowed": False, + "export_script": "scripts/export_1c_extension_sources.ps1", + } + assert manifest["external_reference"]["usage"] == "external_reference_only_not_vendored" + assert len(manifest["external_reference"]["commit"]) == 40 + assert {item["kind"] for item in manifest["fixtures"]} == { + "CalculationRegister", + "Sequence", + "Interface", + } + + +def test_offline_check_does_not_claim_live_readiness() -> None: + result = fixtures.check_manifest(MANIFEST) + + assert result["passed"] is True + assert result["status"] == "manifest_valid" + assert result["counts"] == {"findings": 0, "fixtures": 3, "ready": 0, "gaps": 0} + assert {item["status"] for item in result["fixtures"]} == {"not_checked"} + + +def test_live_check_distinguishes_ready_missing_and_unconfigured() -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + def fake_rpc(_url: str, method: str, payload: dict[str, Any], _timeout: float, _token: str) -> dict[str, Any]: + calls.append((method, payload)) + if method == "extension.objects.find": + return { + "status": "ok", + "objects": [ + { + "kind": "CalculationRegister", + "name": "tt_РегистрРасчета1", + "guid": "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5", + "activation_state": "saved_only", + "extension_guid": "fb26cf42-7609-11f1-828f-005056b0d483", + }, + { + "kind": "Catalog", + "name": "tt_Последовательность1", + "guid": "11111111-1111-1111-1111-111111111111", + "activation_state": "saved_only", + }, + ], + } + return {"status": "ok"} + + result = fixtures.check_manifest(MANIFEST, live=True, rpc_call=fake_rpc) + + by_kind = {item["kind"]: item for item in result["fixtures"]} + assert by_kind["CalculationRegister"]["status"] == "fixture_ready" + assert by_kind["Sequence"]["status"] == "fixture_missing" + assert by_kind["Interface"]["status"] == "target_not_configured" + assert [item["method"] for item in by_kind["CalculationRegister"]["smoke"]] == [ + "metadata.object.get", + "metadata.object.properties", + "metadata.object.attributes", + "metadata.object.related", + ] + extension_find_calls = [payload for method, payload in calls if method == "extension.objects.find"] + assert len(extension_find_calls) == 1 + calc_find_payload = extension_find_calls[0] + assert "kind" not in calc_find_payload + assert calc_find_payload["use_cache"] is True + assert calc_find_payload["full_scan"] is False + assert calc_find_payload["refresh_cache"] is False + assert result["passed"] is True + assert result["status"] == "gaps_found" + + +def test_require_ready_fails_when_a_fixture_is_missing() -> None: + def fake_rpc(_url: str, method: str, _payload: dict[str, Any], _timeout: float, _token: str) -> dict[str, Any]: + if method == "metadata.objects.list": + return {"status": "not_found", "objects": []} + return {"status": "not_found", "objects": []} + + result = fixtures.check_manifest( + MANIFEST, + live=True, + require_ready=True, + base_overrides={"interface_legacy_base": "legacy_test"}, + rpc_call=fake_rpc, + ) + + assert result["passed"] is False + assert result["counts"]["ready"] == 0 + assert result["counts"]["gaps"] == 3 + + +def test_live_check_reuses_extension_scan_transport_failure() -> None: + calls = 0 + + def fake_rpc(_url: str, _method: str, _payload: dict[str, Any], _timeout: float, _token: str) -> dict[str, Any]: + nonlocal calls + calls += 1 + raise TimeoutError("scan timed out") + + result = fixtures.check_manifest(MANIFEST, live=True, rpc_call=fake_rpc) + + by_kind = {item["kind"]: item for item in result["fixtures"]} + assert calls == 1 + assert by_kind["CalculationRegister"]["error"] == "TimeoutError" + assert "probe_error_reused" not in by_kind["CalculationRegister"] + assert by_kind["Sequence"]["error"] == "TimeoutError" + assert by_kind["Sequence"]["probe_error_reused"] is True + + +def test_manifest_rejects_credential_like_keys(tmp_path: Path) -> None: + value = fixtures.load_json(MANIFEST) + value["fixtures"][0]["password"] = "must-not-be-here" + path = tmp_path / "manifest.json" + path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8") + + result = fixtures.check_manifest(path) + + assert result["passed"] is False + assert any(item["code"] == "credential_key_in_manifest" for item in result["findings"]) + + +def test_manifest_requires_safe_designer_access_and_pinned_reference() -> None: + value = fixtures.load_json(MANIFEST) + value["designer"]["access_mode"] = "interactive_credentials" + value["external_reference"]["commit"] = "main" + + findings = fixtures.validate_manifest(value) + + assert {item["code"] for item in findings} >= { + "unsafe_designer_access_mode", + "invalid_external_reference_commit", + } + + +def test_designer_export_script_has_no_user_or_credential_parameters() -> None: + script = (ROOT / "scripts" / "export_1c_extension_sources.ps1").read_text(encoding="utf-8") + + assert "/WA+" in script + assert "/N" not in script + assert "/P" not in script + assert "UserName" not in script + assert "Credential" not in script + assert "/DumpConfigToFiles" in script + assert "-Extension" in script diff --git a/tests/1c/test_object_property_write.py b/tests/1c/test_object_property_write.py new file mode 100644 index 0000000..b7d6822 --- /dev/null +++ b/tests/1c/test_object_property_write.py @@ -0,0 +1,584 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "plugins" / "1c")) +sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector")) + +import adapter_1c_server as adapter # noqa: E402 + + +OBJECT_GUID = "11111111-2222-3333-4444-555555555555" + + +def atom(value: Any) -> dict[str, Any]: + return {"type": "atom", "value": str(value)} + + +def string(value: Any) -> dict[str, Any]: + return {"type": "string", "value": str(value)} + + +def seq(*items: dict[str, Any]) -> dict[str, Any]: + return {"type": "list", "items": list(items)} + + +def identity_tree(*, synonym: str = "Товары", comment: str = "Комментарий") -> dict[str, Any]: + return seq( + seq(atom(1), atom(0), atom(OBJECT_GUID)), + string("Номенклатура"), + seq(atom(1), string("ru"), string(synonym)), + string(comment), + atom(0), + ) + + +def identity_tree_with_member(*, member_synonym: str = "Артикул товара") -> dict[str, Any]: + return seq( + seq(atom(1), atom(0), atom(OBJECT_GUID)), + string("Номенклатура"), + seq(atom(1), string("ru"), string("Товары")), + string("Комментарий"), + seq( + seq(atom(1), atom(0), atom("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")), + string("Артикул"), + seq(atom(1), string("ru"), string(member_synonym)), + string("Комментарий реквизита"), + ), + atom(0), + ) + + +def declared_attribute_tree(*, include_new: bool = False) -> dict[str, Any]: + records = [ + seq( + seq(atom(1), atom(0), atom("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")), + string("Артикул"), + seq(atom(1), string("ru"), string("Артикул товара")), + string("Комментарий реквизита"), + atom("type-settings"), + ) + ] + if include_new: + new_guid = adapter.deterministic_member_guid( + OBJECT_GUID, + "Attribute", + "КодПоставщика", + scope="Catalog.Номенклатура", + ) + records.append( + seq( + seq(atom(1), atom(0), atom(new_guid)), + string("КодПоставщика"), + seq(atom(1), string("ru"), string("Код поставщика")), + string("Комментарий реквизита"), + atom("type-settings"), + ) + ) + return seq( + seq(atom(1), atom(0), atom(OBJECT_GUID)), + string("Номенклатура"), + seq(atom(1), string("ru"), string("Товары")), + string("Комментарий"), + seq(atom(0), atom(len(records)), *records), + atom(0), + ) + + +def prepare_collision() -> dict[str, Any]: + return { + "schema": "onec_saved_state_prepare.v1", + "status": "blocked_target_collision", + "base_id": "base", + "target": {"table": "ConfigSave"}, + "object": { + "kind": "Catalog", + "name": "Номенклатура", + "synonym": "Товары", + "guid": OBJECT_GUID, + }, + "file_names": ["object.0"], + "counts": {"file_names": 1, "target_rows": 1}, + } + + +def base_payload(**overrides: Any) -> dict[str, Any]: + return { + "base_id": "base", + "ref": "Catalog.Номенклатура", + "property": "synonym", + "value": "Номенклатура товаров", + "allow_saved_state_write": True, + **overrides, + } + + +def install_common_mocks(monkeypatch, *, tree: dict[str, Any] | None = None) -> None: + monkeypatch.setattr(adapter, "repository_apply_gate", lambda *_args, **_kwargs: None) + monkeypatch.setattr(adapter, "metadata_saved_state_prepare", lambda _payload: prepare_collision()) + monkeypatch.setattr( + adapter, + "read_storage_file_bytes", + lambda *_args, **_kwargs: (b"payload", {"database": "db"}, None), + ) + monkeypatch.setattr(adapter, "parse_config_tree_from_bytes", lambda _data: tree or identity_tree()) + + +def test_identity_property_target_resolves_existing_synonym_and_comment_paths() -> None: + tree = identity_tree() + + synonym = adapter.config_tree_identity_property_target(tree, OBJECT_GUID, "синоним", language="ru") + comment = adapter.config_tree_identity_property_target(tree, OBJECT_GUID, "комментарий") + + assert synonym["status"] == "ok" + assert synonym["path"] == "2.2" + assert synonym["current"] == "Товары" + assert synonym["node_type"] == "string" + assert comment["status"] == "ok" + assert comment["path"] == "3" + assert comment["current"] == "Комментарий" + + +def test_member_identity_resolves_full_public_path_by_semantic_category(monkeypatch) -> None: + member_guid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + tree = identity_tree_with_member() + monkeypatch.setattr( + adapter, + "nested_metadata_semantic_index", + lambda *_args, **_kwargs: { + member_guid: { + "category": "Attribute", + "ref": "Catalog.Номенклатура.Attribute.Артикул", + } + }, + ) + + resolved = adapter.config_tree_member_identity_resolve( + tree, + parent_guid=OBJECT_GUID, + parent_kind="Catalog", + parent_name="Номенклатура", + member_path=["Реквизит", "Артикул"], + member_name="Артикул", + ) + + assert resolved["status"] == "ok" + assert resolved["member"]["guid"] == member_guid + assert resolved["member"]["kind"] == "Attribute" + assert resolved["member"]["ref"] == "Catalog.Номенклатура.Attribute.Артикул" + + +def test_declared_member_record_resolves_append_container() -> None: + resolved = adapter.config_tree_declared_record_for_guid( + declared_attribute_tree(), + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ) + + assert resolved["status"] == "ok" + assert resolved["record_path"] == "4.2" + assert resolved["parent_path"] == "4" + + +def test_object_property_plan_uses_exact_guid_path_and_compare_guard(monkeypatch) -> None: + install_common_mocks(monkeypatch) + captured: dict[str, Any] = {} + + def fake_propose(payload: dict[str, Any]) -> dict[str, Any]: + captured.update(payload) + return { + "status": "accepted_for_review", + "source": payload["source"], + "original": {"sha1": "old"}, + "encoded": {"sha1": "new"}, + "edits": payload["edits"], + "validation": {"status": "ok", "mode": "path_preserve_format"}, + } + + monkeypatch.setattr(adapter, "changes_propose", fake_propose) + + result = adapter.metadata_object_property_write(base_payload(expected_old="Товары")) + + assert result["status"] == "planned" + assert result["object"]["ref"] == "Catalog.Номенклатура" + assert result["property"] == { + "name": "synonym", + "language": "ru", + "current": "Товары", + "requested": "Номенклатура товаров", + } + assert captured["source"]["table"] == "ConfigSave" + assert captured["edits"] == [ + { + "path": "2.2", + "value": "Номенклатура товаров", + "node_type": "string", + "expected_old": "Товары", + "property": "synonym", + } + ] + assert "source" not in result["proposal"] + + +def test_object_property_plan_returns_name_first_prepare_call(monkeypatch) -> None: + monkeypatch.setattr(adapter, "repository_apply_gate", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + adapter, + "metadata_saved_state_prepare", + lambda _payload: { + "status": "plan_ready", + "base_id": "base", + "target": {"table": "ConfigSave"}, + "source": {"table": "Config"}, + "object": {"kind": "Catalog", "name": "Номенклатура", "guid": OBJECT_GUID}, + "file_names": ["object.0"], + "counts": {"source_rows": 1, "target_rows": 0}, + }, + ) + + result = adapter.metadata_object_property_write(base_payload()) + + assert result["status"] == "needs_prepare" + assert result["next_call"] == { + "method": "metadata.saved_state.prepare", + "params": { + "base_id": "base", + "layer": "base_saved_state", + "ref": "Catalog.Номенклатура", + "kind": "Catalog", + "name": "Номенклатура", + "mode": "plan", + }, + } + assert "table" not in result["next_call"]["params"] + assert "file_name" not in result["next_call"]["params"] + + +def test_object_property_plan_writes_existing_member_by_name(monkeypatch) -> None: + install_common_mocks(monkeypatch, tree=identity_tree_with_member()) + captured: dict[str, Any] = {} + + def fake_propose(payload: dict[str, Any]) -> dict[str, Any]: + captured.update(payload) + return { + "status": "accepted_for_review", + "source": payload["source"], + "original": {"sha1": "old"}, + "encoded": {"sha1": "new"}, + "edits": payload["edits"], + "validation": {"status": "ok", "mode": "path_preserve_format"}, + } + + monkeypatch.setattr(adapter, "changes_propose", fake_propose) + + result = adapter.metadata_object_property_write( + base_payload( + member_name="Артикул", + value="Код товара поставщика", + expected_old="Артикул товара", + ) + ) + + assert result["status"] == "planned" + assert result["member"]["name"] == "Артикул" + assert result["member"]["guid"] == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + assert captured["edits"][0]["path"] == "4.2.2" + assert captured["edits"][0]["expected_old"] == "Артикул товара" + + +def test_object_property_apply_verify_and_rollback(monkeypatch) -> None: + install_common_mocks(monkeypatch) + reads = iter([(b"old", {"database": "db"}, None), (b"new", {"database": "db"}, None)]) + monkeypatch.setattr(adapter, "read_storage_file_bytes", lambda *_args, **_kwargs: next(reads)) + monkeypatch.setattr( + adapter, + "parse_config_tree_from_bytes", + lambda data: identity_tree(synonym="Номенклатура товаров" if data == b"new" else "Товары"), + ) + monkeypatch.setattr( + adapter, + "changes_propose", + lambda payload: { + "status": "accepted_for_review", + "source": payload["source"], + "original": {"sha1": "old"}, + "encoded": {"sha1": "new", "payload_hex": "00"}, + "edits": payload["edits"], + "validation": {"status": "ok", "mode": "path_preserve_format"}, + }, + ) + monkeypatch.setattr( + adapter, + "storage_saved_state_apply_proposal", + lambda _payload: {"status": "applied", "applied": True, "backup": {"backup_id": "backup-1"}}, + ) + monkeypatch.setattr( + adapter, + "storage_saved_state_rollback", + lambda payload: {"status": "applied", "applied": True, "backup_id": payload["backup_id"]}, + ) + + result = adapter.metadata_object_property_write( + base_payload( + execution_mode="apply_and_rollback", + allow_sql_saved_state_apply=True, + allow_sql_saved_state_rollback=True, + ) + ) + + assert result["status"] == "verified_and_rolled_back" + assert result["applied"] is True + assert result["rolled_back"] is True + assert result["semantic_verification"] == { + "status": "ok", + "expected": "Номенклатура товаров", + "actual": "Номенклатура товаров", + } + + +def test_object_property_write_rejects_rename_and_expected_old_drift(monkeypatch) -> None: + install_common_mocks(monkeypatch) + + rename = adapter.call_method( + adapter.OBJECT_PROPERTY_WRITE_METHOD, + base_payload(property="name", value="НовоеИмя"), + ) + drift = adapter.metadata_object_property_write(base_payload(expected_old="Старое значение")) + + assert rename["status"] == "invalid_argument" + assert rename["argument"] == "property" + assert drift["status"] == "precondition_failed" + assert drift["error"] == "expected_old_mismatch" + + +def test_metadata_write_routes_object_property_by_public_selector(monkeypatch) -> None: + monkeypatch.setattr(adapter, "repository_apply_gate", lambda *_args, **_kwargs: None) + captured: dict[str, Any] = {} + + def fake_write(payload: dict[str, Any]) -> dict[str, Any]: + captured.update(payload) + return {"status": "planned", "property": {"name": payload["property"]}} + + monkeypatch.setattr(adapter, "metadata_object_property_write", fake_write) + + result = adapter.metadata_write( + { + "base_id": "base", + "target": { + "area": "object", + "kind": "Catalog", + "name": "Номенклатура", + "property": "comment", + }, + "value": "Пояснение", + "execution_mode": "plan", + } + ) + + assert result["status"] == "planned" + assert result["routed_method"] == adapter.OBJECT_PROPERTY_WRITE_METHOD + assert captured["kind"] == "Catalog" + assert captured["name"] == "Номенклатура" + assert captured["property"] == "comment" + assert captured["value"] == "Пояснение" + assert captured["allow_saved_state_write"] is True + + +def test_object_member_add_plan_clones_attribute_and_generates_guid(monkeypatch) -> None: + install_common_mocks(monkeypatch, tree=declared_attribute_tree()) + monkeypatch.setattr( + adapter, + "nested_metadata_semantic_index", + lambda *_args, **_kwargs: { + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": { + "category": "Attribute", + "ref": "Catalog.Номенклатура.Attribute.Артикул", + } + }, + ) + captured: dict[str, Any] = {} + + def fake_propose(payload: dict[str, Any]) -> dict[str, Any]: + captured.update(payload) + return { + "status": "accepted_for_review", + "source": payload["source"], + "original": {"sha1": "old"}, + "encoded": {"sha1": "new"}, + "edits": [{"mode": "structural_append_child_preserve_format"}], + "validation": {"status": "ok", "mode": "path_preserve_format"}, + } + + monkeypatch.setattr(adapter, "changes_propose", fake_propose) + + result = adapter.metadata_object_member_add( + { + "base_id": "base", + "template_member_ref": "Catalog.Номенклатура.Attribute.Артикул", + "new_member_name": "КодПоставщика", + "new_member_synonym": "Код поставщика", + "allow_saved_state_write": True, + "execution_mode": "plan", + } + ) + + new_guid = adapter.deterministic_member_guid( + OBJECT_GUID, + "Attribute", + "КодПоставщика", + scope="Catalog.Номенклатура", + ) + append = captured["edits"][0]["append_child"] + cloned_identities = adapter.config_tree_identity_records(append["node"]) + assert result["status"] == "planned" + assert result["requested_member"]["guid"] == new_guid + assert result["requested_member"]["ref"] == "Catalog.Номенклатура.Attribute.КодПоставщика" + assert result["container"] == {"ref": "Catalog.Номенклатура", "scope": "object"} + assert append["parent_path"] == "4" + assert cloned_identities[new_guid]["name"] == "КодПоставщика" + assert cloned_identities[new_guid]["synonyms"]["ru"] == "Код поставщика" + + +def test_object_member_add_scopes_tabular_column_guid_and_ref(monkeypatch) -> None: + install_common_mocks(monkeypatch, tree=declared_attribute_tree()) + monkeypatch.setattr( + adapter, + "nested_metadata_semantic_index", + lambda *_args, **_kwargs: { + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": { + "category": "Attribute", + "ref": "Catalog.Номенклатура.TabularSection.Единицы.Attribute.Артикул", + } + }, + ) + monkeypatch.setattr( + adapter, + "changes_propose", + lambda payload: { + "status": "accepted_for_review", + "source": payload["source"], + "original": {"sha1": "old"}, + "encoded": {"sha1": "new"}, + "edits": [{"mode": "structural_append_child_preserve_format"}], + "validation": {"status": "ok", "mode": "path_preserve_format"}, + }, + ) + + result = adapter.metadata_object_member_add( + { + "base_id": "base", + "template_member_ref": "Catalog.Номенклатура.TabularSection.Единицы.Attribute.Артикул", + "new_member_name": "КодПоставщика", + "allow_saved_state_write": True, + "execution_mode": "plan", + } + ) + + expected_guid = adapter.deterministic_member_guid( + OBJECT_GUID, + "Attribute", + "КодПоставщика", + scope="Catalog.Номенклатура.TabularSection.Единицы", + ) + assert result["status"] == "planned" + assert result["container"] == { + "ref": "Catalog.Номенклатура.TabularSection.Единицы", + "scope": "tabular_section", + } + assert result["requested_member"]["ref"] == ( + "Catalog.Номенклатура.TabularSection.Единицы.Attribute.КодПоставщика" + ) + assert result["requested_member"]["guid"] == expected_guid + + +def test_object_member_add_apply_verify_and_rollback(monkeypatch) -> None: + install_common_mocks(monkeypatch, tree=declared_attribute_tree()) + reads = iter([(b"old", {"database": "db"}, None), (b"new", {"database": "db"}, None)]) + monkeypatch.setattr(adapter, "read_storage_file_bytes", lambda *_args, **_kwargs: next(reads)) + monkeypatch.setattr( + adapter, + "parse_config_tree_from_bytes", + lambda data: declared_attribute_tree(include_new=data == b"new"), + ) + monkeypatch.setattr( + adapter, + "nested_metadata_semantic_index", + lambda *_args, **_kwargs: { + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": { + "category": "Attribute", + "ref": "Catalog.Номенклатура.Attribute.Артикул", + } + }, + ) + monkeypatch.setattr( + adapter, + "changes_propose", + lambda payload: { + "status": "accepted_for_review", + "source": payload["source"], + "original": {"sha1": "old"}, + "encoded": {"sha1": "new", "payload_hex": "00"}, + "edits": [{"mode": "structural_append_child_preserve_format"}], + "validation": {"status": "ok", "mode": "path_preserve_format"}, + }, + ) + monkeypatch.setattr( + adapter, + "storage_saved_state_apply_proposal", + lambda _payload: {"status": "applied", "applied": True, "backup": {"backup_id": "backup-add"}}, + ) + monkeypatch.setattr( + adapter, + "storage_saved_state_rollback", + lambda _payload: {"status": "applied", "applied": True}, + ) + + result = adapter.metadata_object_member_add( + { + "base_id": "base", + "template_member_ref": "Catalog.Номенклатура.Attribute.Артикул", + "new_member_name": "КодПоставщика", + "new_member_synonym": "Код поставщика", + "allow_saved_state_write": True, + "allow_sql_saved_state_apply": True, + "allow_sql_saved_state_rollback": True, + "execution_mode": "apply_and_rollback", + } + ) + + assert result["status"] == "verified_and_rolled_back" + assert result["semantic_verification"]["status"] == "ok" + assert result["rolled_back"] is True + + +def test_metadata_write_routes_add_attribute(monkeypatch) -> None: + monkeypatch.setattr(adapter, "repository_apply_gate", lambda *_args, **_kwargs: None) + captured: dict[str, Any] = {} + + def fake_add(payload: dict[str, Any]) -> dict[str, Any]: + captured.update(payload) + return {"status": "planned"} + + monkeypatch.setattr(adapter, "metadata_object_member_add", fake_add) + + result = adapter.metadata_write( + { + "base_id": "base", + "target": { + "area": "object", + "operation": "add_attribute", + "template_member_ref": "Catalog.Номенклатура.Attribute.Артикул", + "new_member_name": "КодПоставщика", + }, + "mode": "plan", + } + ) + + assert result["status"] == "planned" + assert result["routed_method"] == adapter.OBJECT_MEMBER_ADD_METHOD + assert captured["template_member_ref"] == "Catalog.Номенклатура.Attribute.Артикул" + assert captured["new_member_name"] == "КодПоставщика" + assert captured["allow_saved_state_write"] is True diff --git a/tests/1c/test_payload_codec.py b/tests/1c/test_payload_codec.py index 85b6fc0..ffa95dd 100644 --- a/tests/1c/test_payload_codec.py +++ b/tests/1c/test_payload_codec.py @@ -4,6 +4,8 @@ import sys import base64 import hashlib import json +import sqlite3 +import time from pathlib import Path from typing import Any @@ -34,6 +36,7 @@ from parser.cas_payload import classify_payload, replace_stream_block, stream_he from parser.bsl_validation import replace_routine_text, routine_blocks, text_sha1, validate_bsl_text # noqa: E402 from parser.form_payload import command_handler_links, data_path_reference_from_node, decode_form_payload, dynamic_list_fields, dynamic_list_settings, embedded_table_addition_row, enrich_form_common_semantic, enrich_item_data_paths, enrich_item_reference_semantics, event_handlers, event_handlers_from_node, form_common_semantic, section_record_semantic_properties # noqa: E402 from parser.form_xml import decode_form_xml # noqa: E402 +from parser.dbnames import parse_dbnames_bytes, parse_dbnames_version_bytes # noqa: E402 sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector")) import adapter_1c_server as adapter_server # noqa: E402 from adapter_1c_server import call_method, filter_related_children_by_identity, limit_object_commands_result, module_text_response, payload_public_preview, payload_public_properties, payload_public_undecoded_evidence, public_form_profile, public_form_row, public_metadata_item, public_module_row, public_semantic_profile, search_modules, strip_storage_traces # noqa: E402 @@ -49,6 +52,158 @@ import scripts.audit_1c_access_role as access_role_audit_script # noqa: E402 import scripts.compare_1c_form_sql_xml as form_sql_xml_compare_script # noqa: E402 +def test_dbnames_version_parser_accepts_utf8_bom_scalar_version() -> None: + version = "E27A62F5-8C82-4132-BD16-281F9E47DEFC" + + parsed = parse_dbnames_version_bytes(f"{{0,{version}}}".encode("utf-8-sig")) + + assert parsed["marker"] == 0 + assert parsed["version"] == version.lower() + assert parsed["encoding"] == "utf-8-sig" + assert parsed["compression"] == "none" + + +@pytest.mark.parametrize( + ("payload", "message"), + [ + (b"", "expected root list"), + (b"{0,{version}}", "expected scalar marker and version"), + (b"{0,not-a-guid}", "version must be a GUID string"), + ], +) +def test_dbnames_version_parser_rejects_invalid_shape(payload: bytes, message: str) -> None: + with pytest.raises(ValueError, match=message): + parse_dbnames_version_bytes(payload) + + +def test_dbnames_records_parser_keeps_existing_contract() -> None: + parsed = parse_dbnames_bytes(b'{12700,{1,{11111111-1111-1111-1111-111111111111,"Role",7}}}') + + assert parsed["declared_count"] == 1 + assert len(parsed["records"]) == 1 + assert parsed["records"][0].storage_role == "Role" + + +def test_dbnames_sequence_role_maps_to_public_sequence_kind() -> None: + parsed = parse_dbnames_bytes(b'{12700,{1,{11111111-1111-1111-1111-111111111111,"Sequence",7}}}') + record = parsed["records"][0] + + assert adapter_server.DBNAMES_ROLE_KIND[record.storage_role] == "Sequence" + assert adapter_server.PUBLIC_KIND["Sequence"] == "sequence" + assert adapter_server.ONEC_CODE_ROOT_BY_KIND["Sequence"] == "Последовательности" + + +def test_dbnames_summary_routes_version_to_separate_parser(monkeypatch: pytest.MonkeyPatch) -> None: + payloads = { + "DBNames": b'{12700,{1,{11111111-1111-1111-1111-111111111111,"Role",7}}}', + "DBNamesVersion": b"\xef\xbb\xbf{0,e27a62f5-8c82-4132-bd16-281f9e47defc}", + } + monkeypatch.setattr( + adapter_server, + "storage_files_list", + lambda _payload: {"status": "ok", "files": [{"FileName": name} for name in payloads]}, + ) + monkeypatch.setattr( + adapter_server, + "read_storage_file_bytes", + lambda _base_id, _table, file_name, **_kwargs: (payloads[file_name], {}, None), + ) + + result = adapter_server.metadata_dbnames_summary({"base_id": "upo_test", "diagnostic": True}) + + assert result["status"] == "ok" + assert result["counts"] == {"files": 2, "records": 1} + summaries = {item["file_name"]: item for item in result["dbnames"]} + assert summaries["DBNames"]["records"] == 1 + assert summaries["DBNamesVersion"]["marker"] == 0 + assert summaries["DBNamesVersion"]["version"] == "e27a62f5-8c82-4132-bd16-281f9e47defc" + + +def test_live_dbnames_records_ignores_dbnames_version(monkeypatch: pytest.MonkeyPatch) -> None: + seen: list[str] = [] + monkeypatch.setattr( + adapter_server, + "storage_files_list", + lambda _payload: {"status": "ok", "files": [{"FileName": "DBNames"}, {"FileName": "DBNamesVersion"}]}, + ) + + def read_file(_base_id: str, _table: str, file_name: str, **_kwargs: Any) -> tuple[bytes, dict[str, Any], None]: + seen.append(file_name) + return b'{12700,{1,{11111111-1111-1111-1111-111111111111,"Role",7}}}', {}, None + + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", read_file) + + records, error = adapter_server.live_dbnames_records("upo_test") + + assert error is None + assert len(records or []) == 1 + assert seen == ["DBNames"] + + +@pytest.mark.parametrize("extension_guid", [None, ""]) +def test_mcp_code_search_omits_empty_extension_guid(monkeypatch: pytest.MonkeyPatch, extension_guid: Any) -> None: + captured: dict[str, Any] = {} + + def call_adapter(method: str, payload: dict[str, Any], **_kwargs: Any) -> dict[str, Any]: + captured.update(payload) + return {"schema": "onec_code_search.v1", "method": method, "status": "ok", "items": []} + + monkeypatch.setattr(adapter_mcp, "call_adapter_method", call_adapter) + + result = adapter_mcp.run_or_enqueue_adapter_method( + "code.search", + {"base_id": "upo_test", "query": "Найти", "extension_guid": extension_guid, "_mcp_sync": True}, + ) + + assert result["status"] == "ok" + assert "extension_guid" not in captured + + +def test_mcp_code_search_preserves_valid_extension_guid(monkeypatch: pytest.MonkeyPatch) -> None: + extension_guid = "e27a62f5-8c82-4132-bd16-281f9e47defc" + captured: dict[str, Any] = {} + + def call_adapter(method: str, payload: dict[str, Any], **_kwargs: Any) -> dict[str, Any]: + captured.update(payload) + return {"schema": "onec_code_search.v1", "method": method, "status": "ok", "items": []} + + monkeypatch.setattr(adapter_mcp, "call_adapter_method", call_adapter) + + adapter_mcp.run_or_enqueue_adapter_method( + "code.search", + {"base_id": "upo_test", "query": "Найти", "extension_guid": extension_guid, "_mcp_sync": True}, + ) + + assert captured["extension_guid"] == extension_guid + + +def test_code_search_internal_modules_request_omits_empty_extension_selectors(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + monkeypatch.setattr(adapter_server, "code_saved_state_common_form_module_search", lambda *_args, **_kwargs: None) + + def search(payload: dict[str, Any]) -> dict[str, Any]: + captured.update(payload) + return {"status": "ok", "matches": [], "counts": {"matches": 0}} + + monkeypatch.setattr(adapter_server, "search_modules", search) + + result = adapter_server.code_search({"base_id": "upo_test", "query": "Найти", "state": "active"}) + + assert result["status"] == "not_found" + assert "extension" not in captured + assert "extension_guid" not in captured + + +def test_object_full_uses_fast_form_listing_by_default() -> None: + assert adapter_server.adapter_full_forms_method({}) == "metadata.object.forms" + assert adapter_server.adapter_full_forms_method({"include_form_details": False}) == "metadata.object.forms" + + +def test_object_full_uses_form_details_only_when_requested() -> None: + assert adapter_server.adapter_full_forms_method({"include_form_details": True}) == "metadata.object.form.details" + assert adapter_server.adapter_full_forms_method({"include_form_module_text": True}) == "metadata.object.form.details" + + def test_common_form_kind_is_first_class_selector() -> None: assert adapter_server.canonical_kind("Общая форма") == "CommonForm" assert adapter_server.canonical_kind("ОбщиеФормы") == "CommonForm" @@ -58,6 +213,213 @@ def test_common_form_kind_is_first_class_selector() -> None: assert "form_details" in adapter_server.KIND_CAPABILITIES["CommonForm"] +def test_every_public_1c_kind_name_and_code_root_resolves_to_internal_kind() -> None: + for kind, kind_ru in adapter_server.RU_KIND.items(): + assert adapter_server.known_canonical_kind(kind) == kind + assert adapter_server.known_canonical_kind(kind_ru) == kind + for code_root, kind in adapter_server.ONEC_CODE_ROOT_KIND.items(): + assert adapter_server.known_canonical_kind(code_root) == kind + + +def test_runtime_method_registry_is_unique_and_covers_contract_metadata() -> None: + registry = adapter_server.adapter_method_registry() + diagnostics = adapter_server.adapter_method_registry_diagnostics() + response = adapter_server.call_method("help.methods", {}) + + assert diagnostics["status"] == "ok" + assert diagnostics["duplicates"] == [] + assert diagnostics["input_schemas_without_method"] == [] + assert diagnostics["selector_contracts_without_method"] == [] + assert diagnostics["http_routes_without_method"] == [] + assert diagnostics["counts"]["specialized_http_routes"] == 68 + assert diagnostics["counts"]["methods_with_input_schema"] == 151 + assert len(registry) == len(adapter_server.METHODS) == 151 + assert all(row["input_schema"]["type"] == "object" for row in registry.values()) + assert registry["code.search"]["http_routes"] == [{"verb": "POST", "path": "/code/search"}] + assert registry["modules.read"]["http_routes"] == [{"verb": "GET", "path": "/modules/read"}] + assert response["count"] == 151 + assert response["registry"] == diagnostics + + +def test_http_handler_dispatches_specialized_routes_from_registry(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + monkeypatch.setenv("ONEC_ADAPTER_SERVICE_TOKEN", "test-route-token") + + def fake_call_method(method: str, payload: dict[str, Any] | None) -> dict[str, Any]: + calls.append((method, dict(payload or {}))) + return {"status": "ok", "method": method} + + monkeypatch.setattr(adapter_server, "call_method", fake_call_method) + server = adapter_server.ThreadingHTTPServer(("127.0.0.1", 0), adapter_server.Handler) + thread = adapter_server.threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + def request(path: str, body: dict[str, Any] | None = None) -> dict[str, Any]: + data = json.dumps(body).encode("utf-8") if body is not None else None + req = adapter_server.urllib.request.Request( + base_url + path, + data=data, + method="POST" if body is not None else "GET", + headers={ + "Authorization": "Bearer test-route-token", + **({"Content-Type": "application/json"} if body is not None else {}), + }, + ) + with adapter_server.urllib.request.urlopen(req, timeout=5) as response: + return json.loads(response.read().decode("utf-8")) + + try: + assert request("/methods?method=code.search")["method"] == "help.methods" + assert request("/code/search", {"base_id": "upo_test", "query": "Найти"})["method"] == "code.search" + assert request("/rpc", {"method": "metadata.kinds", "payload": {"base_id": "upo_test"}})["method"] == "metadata.kinds" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + assert calls == [ + ("help.methods", {"method": "code.search"}), + ("code.search", {"base_id": "upo_test", "query": "Найти"}), + ("metadata.kinds", {"base_id": "upo_test"}), + ] + + +@pytest.mark.parametrize( + ("ref", "kind", "name"), + [ + ("Справочники.Номенклатура", "Catalog", "Номенклатура"), + ("СправочникСсылка.Номенклатура", "Catalog", "Номенклатура"), + ("ДокументОбъект.Заказ", "Document", "Заказ"), + ("РегистрыСведений.КурсыВалют", "InformationRegister", "КурсыВалют"), + ("РегистрСведенийНаборЗаписей.КурсыВалют", "InformationRegister", "КурсыВалют"), + ("Метаданные.HTTPСервисы.Интеграция", "HTTPService", "Интеграция"), + ("cfg:ОбщиеМодули.Интеграция", "CommonModule", "Интеграция"), + ], +) +def test_code_style_and_type_qualified_object_refs_resolve(ref: str, kind: str, name: str) -> None: + assert adapter_server.parse_object_query(None, ref) == (kind, name) + normalized = adapter_server.normalize_object_ref_payload({"ref": ref}, "test") + assert normalized["kind"] == kind + assert normalized["name"] == name + + +def test_nested_1c_path_keeps_object_identity_and_member_path() -> None: + parsed = adapter_server.parse_1c_object_path("Документы.Заказ.Формы.ФормаДокумента.Элементы.КнопкаЗаписать") + + assert parsed["kind"] == "Document" + assert parsed["name"] == "Заказ" + assert parsed["member_path"] == ["Формы", "ФормаДокумента", "Элементы", "КнопкаЗаписать"] + assert parsed["metadata_ref"] == "Документ.Заказ" + assert parsed["code_ref"] == "Документы.Заказ" + + +def test_data_and_access_surfaces_share_code_style_object_resolution() -> None: + data_payload = adapter_server.data_schema_selector_payload( + {"base_id": "upo_test", "object_ref": "РегистрыСведений.КурсыВалют"} + ) + normalized_data = adapter_server.normalize_object_selector_aliases(data_payload, "data.schema") + normalized_access = adapter_server.normalize_object_selector_aliases( + {"base_id": "upo_test", "ref": "Справочники.Номенклатура"}, + "access.object.roles", + ) + + assert normalized_data["kind"] == "InformationRegister" + assert normalized_data["name"] == "КурсыВалют" + assert normalized_access["kind"] == "Catalog" + assert normalized_access["name"] == "Номенклатура" + assert "access.object.roles" in adapter_server.OBJECT_SELECTOR_ALIAS_METHODS + assert "access.object.subjects" in adapter_server.OBJECT_SELECTOR_ALIAS_METHODS + + +def test_data_methods_publish_name_first_selector_contracts() -> None: + methods = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + } + + assert adapter_server.DATA_OBJECT_SELECTOR_METHODS <= adapter_server.OBJECT_SELECTOR_ALIAS_METHODS + for method in adapter_server.DATA_OBJECT_SELECTOR_METHODS: + capabilities = methods[method]["selector_capabilities"] + properties = methods[method]["input_schema"]["properties"] + assert capabilities["requires_object_selector"] is True + assert capabilities["accepts_object_ref"] is True + assert capabilities["accepts_ordinal"] is False + assert properties["object_ref"]["type"] == "string" + assert properties["object_name"]["type"] == "string" + assert "Справочники.Номенклатура" in properties["object_ref"]["description"] + + +def test_data_selector_keeps_record_ref_separate_from_russian_object_ref() -> None: + record_ref = "A" * 32 + + normalized = adapter_server.normalize_data_object_selector_aliases( + { + "base_id": "upo_test", + "object_ref": "Справочники.Номенклатура", + "ref": record_ref, + }, + "data.get", + ) + + assert normalized["kind"] == "Catalog" + assert normalized["name"] == "Номенклатура" + assert normalized["object_ref"] == "Справочники.Номенклатура" + assert normalized["ref"] == record_ref + assert "guid" not in normalized + + +def test_data_get_dispatch_normalizes_object_name_without_consuming_record_ref( + monkeypatch: pytest.MonkeyPatch, +) -> None: + record_ref = "B" * 32 + captured: dict[str, Any] = {} + + def fake_data_read(payload: dict[str, Any], *, count_only: bool = False, method: str | None = None) -> dict[str, Any]: + captured.update(payload) + return {"schema": "onec_data_result.v1", "status": "ok", "rows": [], "method": method} + + monkeypatch.setattr(adapter_server, "data_read", fake_data_read) + + result = adapter_server.call_method( + "data.get", + { + "base_id": "upo_test", + "object_ref": "Справочники.Номенклатура", + "record_ref": record_ref, + }, + ) + + assert result["status"] == "ok" + assert captured["kind"] == "Catalog" + assert captured["name"] == "Номенклатура" + assert captured["record_ref"] == record_ref + + +def test_data_methods_reject_unsupported_ordinal_selector() -> None: + result = adapter_server.call_method( + "data.schema", + {"base_id": "upo_test", "kind": "Catalog", "ordinal": 1}, + ) + + assert result["status"] == "invalid_argument" + assert result["argument"] == "ordinal" + + +def test_write_plan_path_accepts_code_roots_and_rejects_unknown_roots() -> None: + parsed = adapter_server.metadata_write_plan_path_parts("ОбщиеМодули.Интеграция.Отправить") + unknown = adapter_server.metadata_write_plan_path_parts("НеизвестныеОбъекты.Объект.Поле") + + assert parsed["is_full_path"] is True + assert parsed["kind"] == "CommonModule" + assert parsed["name"] == "Интеграция" + assert parsed["path_kind"] == "module_routine" + assert parsed["canonical_path"] == "ОбщийМодуль.Интеграция.Отправить" + assert parsed["code_path"] == "ОбщиеМодули.Интеграция.Отправить" + assert unknown["is_full_path"] is False + assert unknown["reason"] == "unknown_object_kind" + + def test_root_kind_routes_match_xml_exported_configuration() -> None: assert adapter_server.ROOT_APPLICATION_CLASS_KIND["1c57eabe-7349-44b3-b1de-ebfeab67b47d"] == "CommandGroup" assert adapter_server.ROOT_APPLICATION_CLASS_KIND["36a8e346-9aaa-4af9-bdbd-83be3c177977"] == "DocumentNumerator" @@ -66,6 +428,7 @@ def test_root_kind_routes_match_xml_exported_configuration() -> None: assert adapter_server.ROOT_COLLECTION_KIND["cc9df798-7c94-4616-97d2-7aa0b7bc515e"] == "XDTOPackage" assert adapter_server.ROOT_COLLECTION_KIND["5274d9fc-9c3a-4a71-8f5e-a0db8ab23de5"] == "ExternalDataSource" assert adapter_server.ROOT_COLLECTION_KIND["bf3420b0-f6f9-41a0-b83a-fe9d4ab0b65d"] == "IntegrationService" + assert adapter_server.ROOT_COLLECTION_KIND["a7641777-7813-45c6-96ef-9d51587a6ac6"] == "Interface" assert "DocumentNumerator" in adapter_server.TOP_LEVEL_METADATA_KINDS assert "IntegrationService" in adapter_server.TOP_LEVEL_METADATA_KINDS assert "Configuration" in adapter_server.TOP_LEVEL_METADATA_KINDS @@ -81,6 +444,174 @@ def test_every_kind_specific_sql_decoder_is_advertised_by_capabilities() -> None assert "special_details" in capabilities, kind +@pytest.mark.parametrize( + ("capability", "category", "direct_kinds"), + [ + ("commands", "Command", {"CommandGroup"}), + ("forms", "Form", set()), + ("templates", "Template", {"CommonTemplate"}), + ], +) +def test_advertised_related_capability_has_a_live_collection_rule( + capability: str, + category: str, + direct_kinds: set[str], +) -> None: + advertised = {kind for kind, capabilities in adapter_server.KIND_CAPABILITIES.items() if capability in capabilities} + implemented = { + kind + for kind, rules in adapter_server.RELATED_SECTION_RULES.items() + if any(rule.get("category") == category for rule in rules) + } | direct_kinds + + assert advertised == implemented + + +def test_corrected_related_capabilities_cover_calculation_register_without_false_legacy_claims() -> None: + assert {"forms", "templates", "commands"} <= set(adapter_server.KIND_CAPABILITIES["CalculationRegister"]) + assert "commands" in adapter_server.KIND_CAPABILITIES["CommandGroup"] + assert "commands" not in adapter_server.KIND_CAPABILITIES["Interface"] + assert "templates" not in adapter_server.KIND_CAPABILITIES["ChartOfAccounts"] + assert "templates" not in adapter_server.KIND_CAPABILITIES["ChartOfCalculationTypes"] + + +def test_related_selectors_follow_corrected_capabilities_and_keep_public_names() -> None: + calculation = adapter_server.object_related_selectors( + "upo_test", + { + "kind": "CalculationRegister", + "name": "Начисления", + "guid": "11111111-1111-1111-1111-111111111111", + "ref": "CalculationRegister.Начисления", + }, + ) + interface = adapter_server.object_related_selectors( + "upo_test", + { + "kind": "Interface", + "name": "Основной", + "guid": "22222222-2222-2222-2222-222222222222", + "ref": "Interface.Основной", + }, + ) + + assert calculation["forms"]["ref"] == "CalculationRegister.Начисления" + assert calculation["templates"]["name"] == "Начисления" + assert calculation["commands"]["method"] == "metadata.object.commands" + command_group = adapter_server.object_related_selectors( + "upo_test", + { + "kind": "CommandGroup", + "name": "Сервис", + "guid": "33333333-3333-3333-3333-333333333333", + "ref": "CommandGroup.Сервис", + }, + ) + + assert command_group["commands"]["ref"] == "CommandGroup.Сервис" + assert set(interface) == {"card", "full"} + + +def test_command_group_commands_follow_reverse_common_command_relation(monkeypatch: pytest.MonkeyPatch) -> None: + group_guid = "11111111-1111-1111-1111-111111111111" + first_guid = "22222222-2222-2222-2222-222222222222" + second_guid = "33333333-3333-3333-3333-333333333333" + + def command_tree(command_group_guid: str) -> dict[str, Any]: + atom = lambda value: {"type": "atom", "value": str(value)} + seq = lambda *items: {"type": "list", "items": list(items)} + root = seq() + + def put(path: tuple[int, ...], value: dict[str, Any]) -> None: + node = root + for index in path[:-1]: + items = node.setdefault("items", []) + while len(items) <= index: + items.append(seq()) + node = items[index] + items = node.setdefault("items", []) + while len(items) <= path[-1]: + items.append(seq()) + items[path[-1]] = value + + put((1, 1, 2, 7, 1), atom(command_group_guid)) + return root + + payloads = { + first_guid: serialize_brace_tree(command_tree(group_guid)).encode("utf-8"), + second_guid: serialize_brace_tree(command_tree("44444444-4444-4444-4444-444444444444")).encode("utf-8"), + } + monkeypatch.setattr( + adapter_server, + "list_objects", + lambda *_args, **_kwargs: { + "status": "ok", + "objects": [ + {"kind": "CommonCommand", "guid": first_guid, "name": "Первая", "synonym": "Первая команда", "ref": "CommonCommand.Первая"}, + {"kind": "CommonCommand", "guid": second_guid, "name": "Вторая", "synonym": "Вторая команда", "ref": "CommonCommand.Вторая"}, + ], + }, + ) + monkeypatch.setattr( + adapter_server, + "read_storage_files_bytes", + lambda *_args, **_kwargs: (payloads, {"database": "test"}, None), + ) + + result = adapter_server.metadata_command_group_commands( + base_id="upo_test", + object_card={"kind": "CommandGroup", "guid": group_guid, "name": "Сервис", "ref": "CommandGroup.Сервис"}, + requested_command=None, + include_storage=False, + table="Config", + max_commands=200, + timeout_seconds=30, + ) + + assert result["status"] == "ok" + assert result["counts"]["scanned_common_commands"] == 2 + assert result["counts"]["matched_common_commands"] == 1 + assert result["commands"][0]["ref"] == "CommonCommand.Первая" + assert result["commands"][0]["module"]["read_selector"]["ref"] == "CommonCommand.Первая" + assert result["capabilities"]["reverse_relation"] == "CommonCommand.group" + assert all("storage" not in command for command in result["commands"]) + + +def test_metadata_kinds_combines_sequence_dbnames_and_interface_root_discovery(monkeypatch: pytest.MonkeyPatch) -> None: + sequence_guid = "11111111-1111-1111-1111-111111111111" + interface_guid = "22222222-2222-2222-2222-222222222222" + + class SequenceRecord: + guid = sequence_guid + storage_role = "Sequence" + + monkeypatch.setattr(adapter_server, "live_dbnames_records", lambda *_args, **_kwargs: ([SequenceRecord()], None)) + monkeypatch.setattr( + adapter_server, + "live_base_root_metadata_index", + lambda *_args, **_kwargs: ( + [ + { + "guid": interface_guid, + "kind": "Interface", + "kind_ru": "Интерфейс", + "public_kind": "interface", + "source": "base", + } + ], + [], + ), + ) + + result = adapter_server.get_kinds("upo_test") + + assert result["status"] == "ok" + assert result["internal_kind_counts"]["Sequence"] == 1 + assert result["internal_kind_counts"]["Interface"] == 1 + assert {item["kind"]: item["count"] for item in result["kinds"]}["sequence"] == 1 + assert {item["kind"]: item["count"] for item in result["kinds"]}["interface"] == 1 + + def test_metadata_cache_rebuild_merges_root_only_objects(monkeypatch: pytest.MonkeyPatch) -> None: root_guid = "11111111-1111-1111-1111-111111111111" stored: list[dict[str, Any]] = [] @@ -382,6 +913,208 @@ def test_sql_only_decoders_cover_document_numerator_and_integration_channels() - assert "storage" not in command_group +def test_scheduled_job_schedule_write_edits_use_named_fields_and_preserve_collection_shape() -> None: + atom = lambda value: {"type": "atom", "value": str(value)} + seq = lambda *items: {"type": "list", "items": list(items)} + tree = seq( + atom("00010101000000"), atom("00010101000000"), atom("00010101000001"), atom("00010101000000"), atom("00010101000000"), + atom(0), atom(0), atom(0), atom(2), atom(1), atom(5), + atom(0), atom(0), atom(2), atom(1), atom(12), + atom(1), atom(1), + ) + + result = adapter_server.scheduled_job_schedule_write_edits( + tree, + { + "begin_time": "08:30:00", + "completion_interval": 15, + "week_days": [2, 6], + "months": [2, 11], + }, + ) + + assert result["status"] == "ok" + assert result["requested"] == { + "begin_time": "08:30:00", + "completion_interval": 15, + "week_days": [2, 6], + "months": [2, 11], + } + edits = {edit["path"]: edit for edit in result["edits"]} + assert edits["2"]["value"] == "00010101083000" + assert edits["5"]["value"] == "15" + assert [edits[path]["value"] for path in ("9", "10")] == ["2", "6"] + assert [edits[path]["value"] for path in ("14", "15")] == ["2", "11"] + assert all("expected_old" in edit for edit in result["edits"]) + + +def test_scheduled_job_schedule_write_rebuilds_tree_for_collection_resize() -> None: + atom = lambda value: {"type": "atom", "value": str(value)} + tree = { + "type": "list", + "items": [ + atom("00010101000000"), atom("00010101000000"), atom("00010101000000"), atom("00010101000000"), atom("00010101000000"), + atom(0), atom(0), atom(0), atom(2), atom(1), atom(5), + atom(0), atom(0), atom(1), atom(1), + atom(1), atom(1), + ], + } + + result = adapter_server.scheduled_job_schedule_write_edits( + tree, + {"begin_time": "09:30:00", "week_days": [1, 2, 3], "months": [1, 6, 12]}, + ) + + assert result["status"] == "ok" + assert result["counts"]["resized_collections"] == 2 + assert len(result["edits"]) == 1 + edit = result["edits"][0] + assert edit["resized_collections"] == ["months", "week_days"] + assert edit["old_counts"] == {"week_days": 2, "months": 1} + assert edit["new_counts"] == {"week_days": 3, "months": 3} + rebuilt = adapter_server.scheduled_job_sql_schedule(edit["replace_root"], include_storage=False) + assert rebuilt["begin_time"] == "09:30:00" + assert rebuilt["week_days"] == [1, 2, 3] + assert rebuilt["months"] == [1, 6, 12] + + +def test_metadata_scheduled_job_schedule_write_resolves_public_1c_ref_to_configsave(monkeypatch: pytest.MonkeyPatch) -> None: + guid = "11111111-1111-1111-1111-111111111111" + atom = lambda value: {"type": "atom", "value": str(value)} + tree = { + "type": "list", + "items": [ + atom("00010101000000"), atom("00010101000000"), atom("00010101000000"), atom("00010101000000"), atom("00010101000000"), + atom(0), atom(0), atom(0), atom(1), atom(1), + atom(0), atom(0), atom(1), atom(1), + atom(1), atom(1), + ], + } + captured: dict[str, Any] = {} + monkeypatch.setattr( + adapter_server, + "get_object", + lambda kind, selector, **_kwargs: { + "status": "ok", + "object": {"kind": kind, "name": "ОбменДанными", "guid": guid, "ref": "ScheduledJob.ОбменДанными"}, + }, + ) + monkeypatch.setattr( + adapter_server, + "read_storage_file_bytes", + lambda *_args, **_kwargs: (b"schedule", {"database": "test"}, None), + ) + monkeypatch.setattr(adapter_server, "parse_config_tree_from_bytes", lambda _data: tree) + + def fake_changes_propose(payload: dict[str, Any]) -> dict[str, Any]: + captured.update(payload) + return { + "schema": "onec_change_proposal.v1", + "status": "accepted_for_review", + "source": payload["source"], + "original": {"sha1": "old"}, + "encoded": {"sha1": "new"}, + } + + monkeypatch.setattr(adapter_server, "changes_propose", fake_changes_propose) + + read_only = adapter_server.metadata_scheduled_job_schedule_write( + { + "base_id": "upo_test", + "target": {"kind": "schedule", "ref": "РегламентныеЗадания.ОбменДанными"}, + "schedule": {"begin_time": "09:15:00"}, + } + ) + + assert read_only["status"] == "blocked" + assert read_only["next_call"]["method"] == "metadata.write" + assert read_only["next_call"]["params"]["target"]["ref"] == "ScheduledJob.ОбменДанными" + assert read_only["next_call"]["params"]["allow_saved_state_write"] is True + assert "payload" not in read_only["next_call"] + + result = adapter_server.metadata_scheduled_job_schedule_write( + { + "base_id": "upo_test", + "target": {"kind": "schedule", "ref": "РегламентныеЗадания.ОбменДанными"}, + "schedule": {"begin_time": "09:15:00"}, + "allow_saved_state_write": True, + "execution_mode": "plan", + } + ) + + assert result["status"] == "planned" + assert result["object"]["ref"] == "ScheduledJob.ОбменДанными" + assert captured["source"]["table"] == "ConfigSave" + assert captured["source"]["file_name"] == f"{guid}.0" + assert captured["edits"] == [{"path": "2", "value": "00010101091500", "node_type": "atom", "expected_old": "00010101000000"}] + assert captured["include_payload"] is False + + +def test_metadata_write_routes_schedule_target_without_changing_existing_form_module_routes(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + monkeypatch.setattr(adapter_server, "normalize_repository_write_context", lambda payload, _method: (dict(payload), None)) + monkeypatch.setattr(adapter_server, "resolve_write_gate_context", lambda payload: payload) + monkeypatch.setattr(adapter_server, "repository_apply_gate", lambda *_args, **_kwargs: None) + monkeypatch.setattr(adapter_server, "metadata_write_save_first_payload", lambda payload, _mode: payload) + monkeypatch.setattr( + adapter_server, + "metadata_scheduled_job_schedule_write", + lambda payload: captured.update(payload) or {"schema": "onec_scheduled_job_schedule_write.v1", "status": "planned"}, + ) + + result = adapter_server.metadata_write( + { + "base_id": "upo_test", + "target": {"kind": "schedule", "ref": "РегламентныеЗадания.ОбменДанными"}, + "schedule": {"begin_time": "09:15:00"}, + "execution_mode": "plan", + } + ) + + assert result["status"] == "planned" + assert result["routed_method"] == "metadata.write:scheduled_job_schedule" + assert result["target_kind"] == "schedule" + assert captured["target"]["ref"] == "РегламентныеЗадания.ОбменДанными" + + +def test_metadata_write_preflight_routes_named_scheduled_job_read_only(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "normalize_repository_write_context", lambda payload, _method: (dict(payload), None)) + monkeypatch.setattr(adapter_server, "resolve_write_gate_context", lambda payload: payload) + monkeypatch.setattr( + adapter_server, + "metadata_scheduled_job_schedule_write", + lambda _payload: { + "schema": "onec_scheduled_job_schedule_write.v1", + "status": "planned", + "object": { + "kind": "ScheduledJob", + "name": "ОбменДанными", + "guid": "11111111-1111-1111-1111-111111111111", + "ref": "ScheduledJob.ОбменДанными", + }, + "source": {"kind": "live_metadata"}, + "proposal": {"original": {"sha1": "abc"}}, + }, + ) + monkeypatch.setattr(adapter_server.repository_control, "write_gate", lambda _payload: {"status": "ok", "allowed": True}) + monkeypatch.setattr(adapter_server, "live_sql_support_gate", lambda _payload: {"status": "ok", "allowed": True}) + + result = adapter_server.metadata_write_preflight( + { + "base_id": "upo_test", + "target": {"kind": "schedule", "ref": "РегламентныеЗадания.ОбменДанными"}, + "schedule": {"begin_time": "09:15:00"}, + } + ) + + assert result["status"] == "ready" + assert result["allowed"] is True + assert result["target"]["ref"] == "ScheduledJob.ОбменДанными" + assert result["route"]["writer"] == "metadata.write:scheduled_job_schedule" + assert result["guards"]["expected_sha1"] == "abc" + assert result["diagnostics"]["read_only"] is True + + def test_service_module_roles_and_full_stream_selection(monkeypatch: pytest.MonkeyPatch) -> None: guid = "10000000-0000-0000-0000-000000000001" monkeypatch.setattr( @@ -479,6 +1212,10 @@ def test_common_module_selects_only_largest_canonical_bsl_stream(monkeypatch: py assert result["status"] == "ok" assert result["counts"]["modules"] == 1 assert result["modules"][0]["kind"] == "common_module" + assert result["modules"][0]["name"] == "ОбщийКод" + assert result["modules"][0]["qualified_name"] == "ОбщийКод" + assert result["modules"][0]["display_name"] == "ОбщийКод" + assert result["modules"][0]["storage_name"] == f"{guid}.0#stream:1" assert result["modules"][0]["sha1"] == "canonical" @@ -1262,6 +1999,112 @@ def test_form_owner_cache_resolves_form_module_ref(tmp_path: Path, monkeypatch: assert cached["bsl_offset"] == 1375 +def test_module_owner_cache_prune_resolves_name_and_supports_dry_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ONEC_ADAPTER_CACHE_DB", str(tmp_path / "adapter-cache.sqlite")) + config = {"server": "sql.example", "database": "upo_test"} + owner_guid = "11111111-1111-1111-1111-111111111111" + other_owner_guid = "22222222-2222-2222-2222-222222222222" + first_ref = "Config:first.0#stream:1" + second_ref = "Config:second.0#stream:2" + other_ref = "Config:other.0#stream:3" + + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_modules_owner_guid_from_selector", + lambda base_id, payload, timeout_seconds: ( + owner_guid, + { + "status": "resolved", + "method": "metadata.objects.list", + "owner_guid": owner_guid, + "object": { + "kind": "CommonModule", + "name": "ОбщегоНазначения", + "guid": owner_guid, + "ref": "CommonModule.ОбщегоНазначения", + }, + }, + ), + ) + adapter_server.metadata_module_owner_cache_upsert( + config, + first_ref, + {"kind": "CommonModule", "name": "ОбщегоНазначения", "guid": owner_guid}, + ) + adapter_server.metadata_module_owner_cache_upsert( + config, + second_ref, + {"kind": "CommonModule", "name": "ОбщегоНазначения", "guid": owner_guid}, + ) + adapter_server.metadata_module_owner_cache_upsert( + config, + other_ref, + {"kind": "CommonModule", "name": "ДругойМодуль", "guid": other_owner_guid}, + ) + + dry_run = adapter_server.metadata_module_owner_cache_prune( + { + "base_id": "upo_test", + "object_type": "CommonModule", + "object_name": "ОбщегоНазначения", + "dry_run": True, + } + ) + + assert dry_run["status"] == "ok" + assert dry_run["dry_run"] is True + assert dry_run["counts"] == {"matched": 2} + assert dry_run["resolved_by"] == "public_object_selector" + assert dry_run["query"]["owner"]["ref"] == "CommonModule.ОбщегоНазначения" + assert "guid" not in dry_run["query"]["owner"] + assert "owner_guid" not in dry_run["query"] + assert adapter_server.metadata_module_owner_cache_lookup(config, first_ref) is not None + + applied = adapter_server.metadata_module_owner_cache_prune( + { + "base_id": "upo_test", + "ref": "CommonModule.ОбщегоНазначения", + "dry_run": False, + } + ) + + assert applied["counts"] == {"matched": 2, "deleted": 2} + assert adapter_server.metadata_module_owner_cache_lookup(config, first_ref) is None + assert adapter_server.metadata_module_owner_cache_lookup(config, second_ref) is None + assert adapter_server.metadata_module_owner_cache_lookup(config, other_ref) is not None + + +def test_module_owner_cache_prune_rejects_unresolved_name(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_modules_owner_guid_from_selector", + lambda base_id, payload, timeout_seconds: ( + None, + { + "status": "not_found", + "selector": {"object_type": "CommonModule", "object_name": "НетТакого"}, + }, + ), + ) + + result = adapter_server.metadata_module_owner_cache_prune( + { + "base_id": "upo_test", + "ref": "CommonModule.НетТакого", + "dry_run": True, + } + ) + + assert result["status"] == "not_found" + assert result["error"] == "owner_not_resolved" + assert result["selector"] == { + "ref": "CommonModule.НетТакого", + "kind": "CommonModule", + "name": "НетТакого", + } + + def test_code_index_verify_reports_cache_hit_verified(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("ONEC_ADAPTER_CACHE_DB", str(tmp_path / "adapter-cache.sqlite")) config = {"server": "sql.example", "database": "upo_test"} @@ -1397,6 +2240,80 @@ def test_form_command_button_write_blocks_without_sql_saved_state(monkeypatch: p assert "SQL storage only" in result["diagnostics"]["source_boundary"] assert any(item.get("workflow") == "xml_analysis_learning" for item in result["next_resolution"]) assert not any(item.get("workflow") == "extension_xml_staging" for item in result["next_resolution"]) + rpc_followups = [item for item in result["next_resolution"] if item.get("method")] + assert rpc_followups + assert all("params" in item and "payload" not in item for item in rpc_followups) + + +def test_form_command_button_write_selects_saved_form_by_owner_without_writing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + owner_guid = "11111111-1111-1111-1111-111111111111" + form_guid = "22222222-2222-2222-2222-222222222222" + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_forms_search", + lambda payload: { + "status": "ok", + "forms": [ + { + "name": "ФормаОбмена", + "owner": {"kind": "DataProcessor", "name": "ПервыйОбмен"}, + "source": {"table": "ConfigSave", "file_name": "first-form"}, + }, + { + "name": "ФормаОбмена", + "owner": {"kind": "DataProcessor", "name": "ВторойОбмен"}, + "source": {"table": "ConfigSave", "file_name": "second-form"}, + }, + ], + "counts": {"forms": 2}, + }, + ) + + result = adapter_server.call_method( + "metadata.form.command_button.write", + { + "base_id": "upo_test", + "ref": "Обработки.ВторойОбмен", + "object_guid": owner_guid, + "form_name": "ФормаОбмена", + "form_guid": form_guid, + "command_name": "Выполнить", + "handler_name": "ОбработатьВыполнение", + "include_storage": True, + }, + ) + + assert result["status"] == "blocked" + assert result["counts"]["has_concrete_saved_state"] is True + assert result["target"]["object"] == "ВторойОбмен" + assert result["target"]["object_guid"] == owner_guid + assert result["target"]["form"] == "ФормаОбмена" + assert result["target"]["form_guid"] == form_guid + assert result["operation"]["command"]["action"] == "ОбработатьВыполнение" + assert result["saved_state_target"]["file_name"] == "second-form" + assert result["diagnostics"]["message"].startswith("A saved-state form target exists") + + +def test_form_command_button_write_help_exposes_guards_and_child_selectors() -> None: + methods = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + } + method = methods["metadata.form.command_button.write"] + + assert method["selector_capabilities"]["read_only"] is False + assert "allow_saved_state_write" in method["selector_capabilities"]["write_guards"] + assert "repository_apply_gate" in method["selector_capabilities"]["write_guards"] + assert method["selector_capabilities"]["child_selector_arguments"] == [ + "command_name", + "button_name", + "handler_name", + ] + assert method["input_schema"]["properties"]["form_guid"]["description"].endswith( + "distinct from the owner GUID." + ) def test_form_command_button_write_reports_saved_state_target_as_plan(monkeypatch: pytest.MonkeyPatch) -> None: @@ -1675,6 +2592,81 @@ def test_form_command_button_verify_resolves_saved_state_by_public_selector(monk assert "forms" not in public_result["saved_state_search"] +def test_form_command_button_verify_disambiguates_same_form_name_by_owner( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, Any] = {} + + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_forms_search", + lambda payload: { + "status": "ok", + "forms": [ + { + "name": "ФормаОбмена", + "owner": {"kind": "DataProcessor", "name": "ПервыйОбмен"}, + "source": {"table": "ConfigSave", "file_name": "first-form"}, + }, + { + "name": "ФормаОбмена", + "owner": {"kind": "DataProcessor", "name": "ВторойОбмен"}, + "source": {"table": "ConfigSave", "file_name": "second-form"}, + }, + ], + "counts": {"forms": 2}, + }, + ) + + def decode_form(payload: dict[str, Any]) -> dict[str, Any]: + seen["file_name"] = payload["file_name"] + return { + "status": "ok", + "profile": { + "commands": [{"path": "5.3", "name": "Выполнить"}], + "items": [{"path": "1.2", "name": "Выполнить", "type_name": "Кнопка"}], + "module": {"routines_sample": [{"name": "Выполнить"}]}, + "command_links": [{"command": "Выполнить", "handler": "Выполнить"}], + "button_command_links": [{"button": "Выполнить", "command": "Выполнить"}], + }, + } + + monkeypatch.setattr(adapter_server, "metadata_form_decode", decode_form) + + result = adapter_server.call_method( + "metadata.form.command_button.verify", + { + "base_id": "upo_test", + "ref": "Обработки.ВторойОбмен", + "form_name": "ФормаОбмена", + "command_name": "Выполнить", + }, + ) + + assert result["status"] == "ok" + assert result["verified"] is True + assert seen["file_name"] == "second-form" + assert result["query"]["owner"] == {"kind": "DataProcessor", "name": "ВторойОбмен"} + assert result["query"]["form"] == "ФормаОбмена" + + +def test_form_command_button_verify_help_exposes_child_name_selectors() -> None: + methods = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + } + method = methods["metadata.form.command_button.verify"] + + assert method["selector_capabilities"]["read_only"] is True + assert method["selector_capabilities"]["form_selector_arguments"] == ["form", "form_name"] + assert method["selector_capabilities"]["child_selector_arguments"] == [ + "command_name", + "button_name", + "handler_name", + ] + assert method["input_schema"]["properties"]["command_name"]["type"] == "string" + + def test_form_write_target_verify_reports_writable_saved_state(monkeypatch: pytest.MonkeyPatch) -> None: def fake_resolve(payload: dict[str, Any]) -> dict[str, Any]: return { @@ -1715,6 +2707,10 @@ def test_form_write_target_verify_reports_writable_saved_state(monkeypatch: pyte assert result["verified"] is True assert result["writable_now"] is True assert result["needs_prepare"] is False + assert result["source"] == { + "kind": "saved_state", + "layer": "extension_saved_state", + } assert result["write_target"]["form_path"] == "5.3.3.2.1" assert "path" not in result["write_target"] assert result["saved_state_search"]["selected_form"]["name"] == "t_Форма" @@ -1750,6 +2746,15 @@ def test_form_write_target_verify_reports_prepare_needed(monkeypatch: pytest.Mon assert result["writable_now"] is False assert result["needs_prepare"] is True assert result["next_resolution"]["method"] == "metadata.saved_state.prepare" + assert result["next_resolution"]["params"] == { + "base_id": "upo_test", + "layer": "extension_saved_state", + "ref": "CommonForm.t_Форма", + "kind": "CommonForm", + "name": "t_Форма", + "extension": "test2", + "mode": "plan", + } assert result["saved_state_search"]["counts"]["forms"] == 0 @@ -1804,6 +2809,94 @@ def test_form_write_target_resolve_prefers_exact_form_among_similar_saved_forms( assert result["target"]["name"] == "РасчетС" +def test_form_write_target_resolve_disambiguates_same_form_name_by_owner(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + first_owner_guid = "11111111-1111-1111-1111-111111111111" + second_owner_guid = "22222222-2222-2222-2222-222222222222" + + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_forms_search", + lambda payload: { + "schema": "onec_saved_state_form_search.v1", + "status": "ok", + "base_id": payload["base_id"], + "forms": [ + { + "name": "ФормаОбмена", + "form": {"name": "ФормаОбмена"}, + "owner": {"kind": "DataProcessor", "name": "ПервыйОбмен", "guid": first_owner_guid}, + "source": {"table": "ConfigCASSave", "file_name": "first-form"}, + }, + { + "name": "ФормаОбмена", + "form": {"name": "ФормаОбмена"}, + "owner": {"kind": "DataProcessor", "name": "ВторойОбмен", "guid": second_owner_guid}, + "source": {"table": "ConfigCASSave", "file_name": "second-form"}, + }, + ], + "counts": {"forms": 2}, + }, + ) + + def fake_form_decode(payload: dict[str, Any]) -> dict[str, Any]: + seen["decode_payload"] = payload + return { + "schema": "onec_form_decode.v1", + "status": "ok", + "source": {"table": payload["table"], "file_name": payload["file_name"]}, + "form": {"name": "ФормаОбмена"}, + "profile": { + "commands": [{"path": "5.3", "name": "РасчетС", "title": "РасчетС", "_profile_section": "commands"}], + "items": [], + "attributes": [], + "tables": [], + "command_bars": [], + }, + } + + monkeypatch.setattr(adapter_server, "metadata_form_decode", fake_form_decode) + + result = adapter_server.call_method( + "metadata.form.write_target.resolve", + { + "base_id": "upo_test", + "ref": "Обработки.ВторойОбмен", + "object_guid": second_owner_guid, + "form_name": "ФормаОбмена", + "command": "РасчетС", + "property": "title", + }, + ) + + assert result["status"] == "ok" + assert seen["decode_payload"]["file_name"] == "second-form" + assert "guid" not in seen["decode_payload"] + assert result["target"]["name"] == "РасчетС" + + +def test_form_write_target_help_separates_owner_form_and_child_selectors() -> None: + methods = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + } + + for method_name in ("metadata.form.write_target.resolve", "metadata.form.write_target.verify"): + method = methods[method_name] + assert method["selector_capabilities"]["accepts_ordinal"] is False + assert method["selector_capabilities"]["read_only"] is True + assert method["selector_capabilities"]["form_selector_arguments"] == ["form", "form_name", "form_guid"] + assert method["selector_capabilities"]["child_selector_arguments"] == [ + "element", + "element_name", + "command", + "attribute", + ] + properties = method["input_schema"]["properties"] + assert properties["object_guid"]["description"].endswith("owner GUID.") + assert properties["form_guid"]["description"].endswith("owner GUID.") + + def test_saved_state_forms_search_names_saved_only_extension_form(monkeypatch: pytest.MonkeyPatch) -> None: form_file = "ext-guid__caee4437-14b6-436f-98e3-360c4577f20d.0" descriptor_file = "ext-guid__caee4437-14b6-436f-98e3-360c4577f20d" @@ -1852,6 +2945,7 @@ def test_saved_state_forms_search_names_saved_only_extension_form(monkeypatch: p "tables": ["ConfigCASSave"], "form": "new_SaveOnlyForm", "limit": 10, + "include_storage": True, } ) @@ -1867,6 +2961,188 @@ def test_saved_state_forms_search_names_saved_only_extension_form(monkeypatch: p assert seen_reads == [("upo_test", "ConfigCASSave", descriptor_file)] +def test_saved_state_forms_search_disambiguates_same_form_name_by_owner(monkeypatch: pytest.MonkeyPatch) -> None: + owner_guids = { + "first-form.0": "11111111-1111-1111-1111-111111111111", + "second-form.0": "22222222-2222-2222-2222-222222222222", + } + owner_names = { + "first-form.0": "ПервыйОбмен", + "second-form.0": "ВторойОбмен", + } + + monkeypatch.setattr( + adapter_server, + "storage_files_list", + lambda payload: { + "status": "ok", + "files": [{"FileName": file_name} for file_name in owner_guids], + }, + ) + + def fake_row(**kwargs: Any) -> dict[str, Any]: + file_name = kwargs["file_name"] + return { + "table": kwargs["table"], + "file_name": file_name, + "name": "ФормаОбмена", + "form": {"name": "ФормаОбмена", "guid": f"{file_name}-guid"}, + "owner": { + "kind": "DataProcessor", + "name": owner_names[file_name], + "guid": owner_guids[file_name], + }, + "source": {"table": kwargs["table"], "file_name": file_name}, + "matches": [], + } + + monkeypatch.setattr(adapter_server, "saved_state_form_search_row", fake_row) + + result = adapter_server.call_method( + "metadata.saved_state.forms.search", + { + "base_id": "upo_test", + "tables": ["ConfigCASSave"], + "ref": "Обработки.ВторойОбмен", + "object_guid": owner_guids["second-form.0"], + "form_name": "ФормаОбмена", + "include_storage": True, + }, + ) + + assert result["status"] == "ok" + assert result["counts"]["forms"] == 1 + assert result["forms"][0]["file_name"] == "second-form.0" + assert result["query"]["owner"] == { + "kind": "DataProcessor", + "name": "ВторойОбмен", + "guid": owner_guids["second-form.0"], + } + assert result["query"]["form"] == "ФормаОбмена" + + +def test_saved_state_forms_search_default_is_name_first(monkeypatch: pytest.MonkeyPatch) -> None: + file_name = "extension-guid__form-guid.0" + monkeypatch.setattr( + adapter_server, + "storage_files_list", + lambda payload: {"status": "ok", "files": [{"FileName": file_name, "PartCount": 1, "Bytes": 100}]}, + ) + monkeypatch.setattr( + adapter_server, + "saved_state_form_search_row", + lambda **kwargs: { + "table": "ConfigCASSave", + "file_name": file_name, + "name": "t_Форма", + "synonym": "T форма", + "owner": { + "status": "resolved", + "kind": "Catalog", + "name": "test2", + "guid": "owner-guid", + "source": "saved_state_descriptor", + }, + "form": { + "name": "t_Форма", + "synonym": "T форма", + "guid": "form-guid", + "file_name": file_name, + "identity": {"guid": "form-guid", "descriptor_file_name": "extension-guid__form-guid"}, + }, + "source": {"kind": "live_sql", "table": "ConfigCASSave", "file_name": file_name}, + "counts": {"items": 1, "commands": 1}, + "matches": [ + { + "name": "ЗаменаДомена", + "id": "1", + "title": "Замена домена", + "path": "5.2", + "marker": "11", + "section": "commands", + "writable_properties": [ + { + "property": "title", + "presentation": "Заголовок", + "path": "5.2.3.2.1", + "value": "Замена домена", + "value_type": "string", + "verification": "source_aware_readback", + } + ], + } + ], + }, + ) + + result = adapter_server.metadata_saved_state_forms_search( + { + "base_id": "upo_test", + "tables": ["ConfigCASSave"], + "ref": "Catalog.test2", + "form": "t_Форма", + "command": "ЗаменаДомена", + } + ) + + assert result["source"] == {"kind": "saved_state"} + assert result["query"]["owner"]["ref"] == "Catalog.test2" + assert result["counts"] == {"forms": 1, "limit": 50} + form = result["forms"][0] + assert form["qualified_name"] == "test2.t_Форма" + assert form["selector"] == { + "ref": "Catalog.test2", + "form": "t_Форма", + "qualified_name": "test2.t_Форма", + } + assert form["matches"][0]["selector"] == { + "ref": "Catalog.test2", + "form": "t_Форма", + "qualified_name": "test2.t_Форма", + "command": "ЗаменаДомена", + } + assert form["matches"][0]["writable_properties"] == [ + { + "property": "title", + "presentation": "Заголовок", + "value": "Замена домена", + "value_type": "string", + "verification": "source_aware_readback", + } + ] + for storage_marker in ( + "ConfigCASSave", + "extension-guid", + "form-guid", + "owner-guid", + "file_name", + "descriptor_file_name", + "path", + "marker", + "live_sql", + ): + assert storage_marker not in str(result) + + +def test_saved_state_forms_search_help_exposes_owner_and_form_selectors() -> None: + method = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + }["metadata.saved_state.forms.search"] + + assert method["selector_capabilities"]["accepts_ordinal"] is False + assert method["selector_capabilities"]["read_only"] is True + assert method["selector_capabilities"]["form_selector_arguments"] == ["form", "form_name", "form_guid"] + assert method["selector_capabilities"]["child_selector_arguments"] == [ + "element", + "element_name", + "command", + "attribute", + ] + assert method["input_schema"]["properties"]["object_guid"]["type"] == "string" + assert method["input_schema"]["properties"]["form_guid"]["type"] == "string" + + def test_access_graph_builds_effective_permissions_with_sources() -> None: access = { "users": [{"id": "u1", "name": "Иванов", "groups": ["g1"]}], @@ -2844,6 +4120,145 @@ def test_access_keys_query_filters_group_keys(monkeypatch: pytest.MonkeyPatch) - assert captured["limit"] == 11 +def test_access_object_record_filters_resolve_public_names_without_consuming_query_kind(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_schema(payload: dict[str, Any]) -> dict[str, Any]: + seen["schema_payload"] = payload + return { + "status": "ok", + "object": { + "kind": "Catalog", + "name": "Номенклатура", + "guid": "11111111-1111-1111-1111-111111111111", + }, + "table": {"name": "_Reference106", "row_kind": "Catalog"}, + } + + monkeypatch.setattr(adapter_server, "data_object_schema", fake_schema) + + normalized, resolution, error = adapter_server.normalize_access_object_record_filters( + { + "base_id": "upo_test", + "kind": "object", + "object_ref": "Справочники.Номенклатура", + "record_ref": "00112233-4455-6677-8899-aabbccddeeff", + }, + "access.keys.query", + "upo_test", + timeout_seconds=60, + ) + + assert error is None + assert normalized["kind"] == "object" + assert normalized["object_sql_number"] == 106 + assert normalized["object_id"] == "00112233445566778899AABBCCDDEEFF" + assert seen["schema_payload"]["object_ref"] == "Справочники.Номенклатура" + assert "kind" not in seen["schema_payload"] + assert resolution["metadata_object"]["ref"] == "Catalog.Номенклатура" + assert resolution["data_record"]["record_ref"] == "00112233445566778899AABBCCDDEEFF" + + +def test_access_keys_query_maps_object_ref_and_record_ref_to_bsp_filters(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + record_ref = "00112233445566778899AABBCCDDEEFF" + + monkeypatch.setattr( + adapter_server, + "data_object_schema", + lambda payload: { + "status": "ok", + "object": {"kind": "Catalog", "name": "Номенклатура"}, + "table": {"name": "_Reference106"}, + }, + ) + monkeypatch.setattr( + adapter_server, + "access_bsp_extractor_plan", + lambda base_id: ( + { + "access_object_keys": ( + "SELECT N'x' AS object, N'0000006A' AS object_type_code, " + "106 AS object_sql_number, N'x' AS object_id, N'k1' AS access_key" + ) + }, + { + "access_object_keys": { + "object": "object", + "object_type_code": "object_type_code", + "object_sql_number": "object_sql_number", + "object_id": "object_id", + "access_key": "access_key", + } + }, + {"preset": "bsp"}, + ), + ) + + def fake_rows(base_id: str, query: str, *, limit: int, timeout_seconds: int) -> tuple[list[dict[str, Any]], None, bool]: + captured["query"] = query + return [], None, False + + monkeypatch.setattr(adapter_server, "access_rows_from_query", fake_rows) + + result = adapter_server.access_keys_query( + { + "base_id": "upo_test", + "kind": "object", + "object_ref": "Справочники.Номенклатура", + "record_ref": record_ref, + } + ) + + assert result["status"] == "ok" + assert "q.[object_sql_number] = 106" in captured["query"] + assert f"q.[object_id] = N'{record_ref}'" in captured["query"] + assert result["selector_resolution"]["metadata_object"]["name"] == "Номенклатура" + assert result["areas"]["access_object_keys"]["filters"]["object_ref"] == "Справочники.Номенклатура" + assert result["areas"]["access_object_keys"]["filters"]["record_ref"] == record_ref + + +def test_access_object_record_filters_reject_conflicting_legacy_id(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "data_object_schema", + lambda payload: { + "status": "ok", + "object": {"kind": "Document", "name": "ЗаказКлиента"}, + "table": {"name": "_Document422"}, + }, + ) + + _, _, error = adapter_server.normalize_access_object_record_filters( + { + "object_ref": "Документы.ЗаказКлиента", + "object_sql_number": 421, + }, + "access.object_keys.resolve", + "upo_test", + timeout_seconds=60, + ) + + assert error is not None + assert error["status"] == "invalid_argument" + assert error["argument"] == "object_sql_number" + + +def test_access_key_help_and_mcp_schemas_distinguish_object_and_record_refs() -> None: + methods = {row["name"]: row for row in adapter_server.call_method("help.methods", {}).get("methods") or []} + mcp_tools = {row["name"]: row for row in adapter_mcp.TOOLS} + + for method in ("access.keys.query", "access.object_keys.resolve", "access.object.explain"): + properties = methods[method]["input_schema"]["properties"] + assert "object_ref" in properties + assert "record_ref" in properties + assert "not a 1C metadata kind" in methods["access.keys.query"]["input_schema"]["properties"]["kind"]["description"] + for tool in ("access_keys_query", "access_object_keys_resolve", "access_object_explain"): + properties = mcp_tools[tool]["inputSchema"]["properties"] + assert "object_ref" in properties + assert "record_ref" in properties + + def test_access_object_roles_resolves_name_to_guid_and_filters_roles(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( adapter_server, @@ -7165,6 +8580,49 @@ def test_metadata_definition_find_reads_saved_command_routine(monkeypatch: pytes assert get_calls[0]["extension_guid"] == extension_guid +def test_metadata_definition_find_accepts_full_code_style_member_path(monkeypatch: pytest.MonkeyPatch) -> None: + object_guid = "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5" + get_calls: list[tuple[str | None, str]] = [] + + def fake_get_object(kind: str | None, name: str, **_kwargs: Any) -> dict[str, Any]: + get_calls.append((kind, name)) + return { + "status": "ok", + "object": { + "kind": "Document", + "kind_ru": "Документ", + "name": "Заказ", + "guid": object_guid, + }, + } + + monkeypatch.setattr(adapter_server, "get_object", fake_get_object) + monkeypatch.setattr( + adapter_server, + "metadata_object_attributes", + lambda _payload: { + "status": "ok", + "dimensions": [], + "resources": [], + "attributes": [{"name": "Автор", "guid": "11111111-1111-1111-1111-111111111111"}], + "tabular_sections": [], + }, + ) + + result = adapter_server.metadata_definition_find( + { + "base_id": "upo_test", + "query": "Документы.Заказ.Реквизиты.Автор", + } + ) + + assert result["status"] == "ok" + assert get_calls == [("Document", "Заказ")] + assert result["counts"]["matches"] == 1 + assert result["matches"][0]["area"] == "object" + assert result["matches"][0]["name"] == "Автор" + + def test_metadata_object_parts_uses_saved_extension_storage_prefix(monkeypatch: pytest.MonkeyPatch) -> None: extension_guid = "fb26cf42-7609-11f1-828f-005056b0d483" object_guid = "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5" @@ -7415,6 +8873,127 @@ def test_metadata_code_index_build_ignores_non_bsl_text_streams(monkeypatch: pyt assert indexed_refs == [f"ConfigCASSave:{file_name}#stream:1"] +def test_metadata_code_index_build_routes_named_base_object_to_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: ({"database": base_id}, None)) + + def get_object(kind: str, name: str, **kwargs: Any) -> dict[str, Any]: + captured.update({"kind": kind, "name": name, "table": kwargs.get("table")}) + return { + "status": "ok", + "object": { + "kind": "CommonModule", + "name": "ОбменДанными", + "guid": "11111111-1111-1111-1111-111111111111", + "ref": "CommonModule.ОбменДанными", + }, + } + + monkeypatch.setattr(adapter_server, "get_object", get_object) + monkeypatch.setattr(adapter_server, "metadata_object_modules", lambda payload: {"status": "ok", "modules": []}) + monkeypatch.setattr(adapter_server, "metadata_object_commands", lambda payload: {"status": "ok", "object_commands": []}) + monkeypatch.setattr(adapter_server, "metadata_object_forms", lambda payload: {"status": "ok", "forms": []}) + + result = adapter_server.call_method( + "metadata.code_index.build", + { + "base_id": "upo_test", + "ref": "ОбщиеМодули.ОбменДанными", + "include_vectors": False, + }, + ) + + assert result["status"] == "ok" + assert captured == {"kind": "CommonModule", "name": "ОбменДанными", "table": "Config"} + assert result["source"]["table"] == "Config" + assert result["query"]["name"] == "ОбменДанными" + + +def test_metadata_code_index_help_publishes_optional_name_scope() -> None: + methods = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + } + method = methods["metadata.code_index.build"] + + assert method["selector_capabilities"]["selector_purpose"] == "optional_index_scope" + assert method["selector_capabilities"]["allows_global_search"] is True + assert method["selector_capabilities"]["accepts_extension"] is True + assert method["input_schema"]["properties"]["object_name"]["type"] == "string" + + +def test_form_owner_index_keeps_owner_and_form_names_separate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, Any] = {} + + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: ({"database": base_id}, None)) + + def find_objects(payload: dict[str, Any]) -> dict[str, Any]: + captured["find"] = payload + return { + "status": "ok", + "objects": [ + { + "kind": "DataProcessor", + "name": "ОбменДанными", + "guid": "11111111-1111-1111-1111-111111111111", + "route": {"table": "ConfigCAS", "file_name": "owner-descriptor"}, + "origin": {"extension": {"name": "test", "guid": "22222222-2222-2222-2222-222222222222"}}, + } + ], + } + + def index_entry(**kwargs: Any) -> tuple[dict[str, Any], None]: + captured["entry"] = kwargs + return ( + { + "owner": {"kind": kwargs["owner_kind"], "name": kwargs["owner_name"]}, + "form": {"name": kwargs["form_name"]}, + "bsl_offset": None, + }, + None, + ) + + monkeypatch.setattr(adapter_server, "extension_objects_find", find_objects) + monkeypatch.setattr(adapter_server, "manifest_related_entries_for_cas_key", lambda *args, **kwargs: ([], [])) + monkeypatch.setattr(adapter_server, "form_owner_index_entry_from_sql_payload", index_entry) + + result = adapter_server.call_method( + "metadata.form.owner_index.build", + { + "base_id": "upo_test", + "ref": "Обработки.ОбменДанными", + "form_name": "ФормаОбмена", + "extension": "test", + }, + ) + + assert result["status"] == "ok" + assert captured["find"]["kind"] == "DataProcessor" + assert captured["find"]["query"] == "ОбменДанными" + assert captured["entry"]["owner_name"] == "ОбменДанными" + assert captured["entry"]["form_name"] == "ФормаОбмена" + assert result["query"]["owner"] == "ОбменДанными" + assert result["query"]["form"] == "ФормаОбмена" + + +def test_form_owner_index_help_publishes_owner_and_form_selectors() -> None: + methods = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + } + method = methods["metadata.form.owner_index.build"] + + assert method["selector_capabilities"]["selector_purpose"] == "optional_form_owner_scope" + assert method["selector_capabilities"]["form_selector_arguments"] == ["form", "form_name"] + assert method["input_schema"]["properties"]["form_name"]["type"] == "string" + assert "Обработки.ОбменДанными" in method["input_schema"]["properties"]["ref"]["description"] + + def test_metadata_code_index_build_resolves_saved_command_files_from_object_name(monkeypatch: pytest.MonkeyPatch) -> None: class FakeStreamClassifier: pass @@ -8090,6 +9669,129 @@ def test_templates_areas_find_accepts_route_ref(monkeypatch: pytest.MonkeyPatch) assert result["areas"][0]["name"] == "ОбластьШапка" +def test_templates_areas_find_normalizes_public_template_ref(monkeypatch: pytest.MonkeyPatch) -> None: + descriptor_key = "a" * 40 + seen: dict[str, Any] = {} + + def fake_find(payload: dict[str, Any]) -> dict[str, Any]: + seen["find_payload"] = payload + return { + "status": "ok", + "objects": [ + { + "object": {"kind": "Template", "name": "ПечатнаяФорма", "guid": descriptor_key}, + "route": {"table": "ConfigCAS", "file_name": descriptor_key, "route_type": "cache"}, + "match_by": "name", + } + ], + } + + monkeypatch.setattr(adapter_server, "extension_objects_find", fake_find) + monkeypatch.setattr( + adapter_server, + "read_template_by_route", + lambda payload, analyze=False: { + "status": "ok", + "templates": [ + { + "name": "ПечатнаяФорма", + "guid": descriptor_key, + "structure": { + "format": "MOXCEL", + "named_areas": [ + { + "name": "Шапка", + "occurrence": 1, + "range": {"one_based": {"top": 1, "left": 1, "bottom": 1, "right": 4}}, + } + ], + }, + } + ], + }, + ) + + result = adapter_server.call_method( + "templates.areas.find", + { + "base_id": "upo_test", + "ref": "Макеты.ПечатнаяФорма", + "area_name": "Шапка", + }, + ) + + assert result["status"] == "ok" + assert seen["find_payload"]["query"] == "ПечатнаяФорма" + assert seen["find_payload"]["kind"] == "Template" + assert result["query"]["query"] == "ПечатнаяФорма" + assert result["query"]["area_query"] == "Шапка" + assert result["areas"][0]["name"] == "Шапка" + + +def test_templates_areas_find_help_separates_template_and_area_selectors() -> None: + method = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + }["templates.areas.find"] + + assert method["selector_capabilities"]["accepts_ordinal"] is False + assert method["selector_capabilities"]["read_only"] is True + assert method["selector_capabilities"]["child_selector_arguments"] == [ + "area_name", + "area_query", + "area_occurrence", + ] + assert method["selector_capabilities"]["direct_route_arguments"] == ["route_ref", "table", "file_name"] + assert method["input_schema"]["properties"]["ref"]["type"] == "string" + assert method["input_schema"]["properties"]["area_name"]["type"] == "string" + + +def test_metadata_cache_lookup_normalizes_public_object_ref(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_lookup(base_id: str, kind: str, name: str) -> dict[str, Any]: + seen["lookup"] = {"base_id": base_id, "kind": kind, "name": name} + return { + "base_id": base_id, + "kind": kind, + "kind_ru": "Документ", + "name": name, + "guid": "document-guid", + "source": "base", + } + + monkeypatch.setattr(adapter_server, "metadata_cache_lookup_row", fake_lookup) + + result = adapter_server.call_method( + "metadata.cache.lookup", + { + "base_id": "upo_test", + "ref": "Документы.ЗаказКлиента", + }, + ) + + assert result["status"] == "ok" + assert seen["lookup"] == { + "base_id": "upo_test", + "kind": "Document", + "name": "ЗаказКлиента", + } + assert result["object"]["name"] == "ЗаказКлиента" + + +def test_metadata_cache_lookup_help_marks_cache_as_non_authoritative() -> None: + method = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + }["metadata.cache.lookup"] + + assert method["selector_capabilities"]["accepts_ordinal"] is False + assert method["selector_capabilities"]["requires_object_selector"] is True + assert method["selector_capabilities"]["read_only"] is True + assert method["selector_capabilities"]["authoritative_source"] == "live_sql_not_cache" + assert method["input_schema"]["properties"]["ref"]["type"] == "string" + + def test_templates_areas_find_filters_by_area_query(monkeypatch: pytest.MonkeyPatch) -> None: descriptor_key = "a" * 40 seen_payloads: list[dict[str, Any]] = [] @@ -9329,15 +11031,39 @@ def test_metadata_adapter_audit_reports_recognized_missing_and_unmapped(monkeypa assert detailed["counts"]["recognized_kinds"] == 2 assert detailed["counts"]["supported_kinds"] == len(adapter_server.TOP_LEVEL_METADATA_KINDS) assert detailed["counts"]["missing_supported_kinds"] == len(adapter_server.TOP_LEVEL_METADATA_KINDS) - 2 - assert any(item["kind"] == "Report" for item in detailed["missing_supported_kinds"]) - assert detailed["metadata_candidates"] == [ - {"source_role": "ExtDataSrcPrms", "category": "platform_feature_candidate", "records": 1} - ] + assert any( + item["kind"] == "Report" + and item["presence_status"] == "supported_absent_in_selected_base" + for item in detailed["missing_supported_kinds"] + ) + assert detailed["metadata_candidates"] == [] assert detailed["unmapped_source_roles"] == [ - {"source_role": "ExtDataSrcPrms", "category": "platform_feature_candidate", "records": 1} + {"source_role": "ExtDataSrcPrms", "category": "auxiliary_storage", "records": 1} ] +def test_dbnames_platform_service_tables_are_not_reported_as_metadata_candidates() -> None: + system_roles = { + "Acoustic", + "Bots", + "Ecs", + "LangModel", + "MobileClientDataExchange", + "STTGrammar", + "STTGrammarChecksum", + "STTModels", + "STTModelsDesc", + "STTSettings", + "URLExternalData", + "WebSocketClients", + } + + assert {adapter_server.classify_unmapped_source_role(role) for role in system_roles} == { + "platform_system_storage" + } + assert adapter_server.classify_unmapped_source_role("ExtDataSrcPrms") == "auxiliary_storage" + + def test_modules_search_returns_public_read_selector_without_storage(monkeypatch: pytest.MonkeyPatch) -> None: class FakeStreamClassifier: pass @@ -9578,6 +11304,43 @@ def test_public_module_row_keeps_public_name_with_storage_details() -> None: assert public["module_id"] == "Config:register-guid.6#stream:4" +def test_public_common_module_uses_1c_owner_name_not_storage_route() -> None: + module = { + "module_id": "Config:0422fa60-6cd4-410c-b2af-8792a6a9b199.0#stream:4", + "name": "0422fa60-6cd4-410c-b2af-8792a6a9b199.0#stream:4", + "storage_name": "0422fa60-6cd4-410c-b2af-8792a6a9b199.0#stream:4", + } + + public = adapter_server.public_module_with_qualified_name( + module, + owner={"kind": "CommonModule", "name": "ОбщегоНазначенияБЭДПовтИсп"}, + include_storage=False, + ordinal=1, + ) + + assert public["kind"] == "common_module" + assert public["name"] == "ОбщегоНазначенияБЭДПовтИсп" + assert public["qualified_name"] == "ОбщегоНазначенияБЭДПовтИсп" + assert public["display_name"] == "ОбщегоНазначенияБЭДПовтИсп" + assert "storage_name" not in public + assert "module_id" not in public + + +def test_public_common_module_without_owner_name_does_not_leak_storage_route() -> None: + public = adapter_server.public_module_row( + { + "module_id": "Config:0422fa60-6cd4-410c-b2af-8792a6a9b199.0#stream:4", + "name": "0422fa60-6cd4-410c-b2af-8792a6a9b199.0#stream:4", + }, + include_storage=False, + ordinal=1, + owner_kind="CommonModule", + ) + + assert public["name"] == "Общий модуль" + assert "0422fa60" not in str(public) + + def test_public_code_qualified_name_joins_owner_form_and_module() -> None: assert adapter_server.public_code_qualified_name( owner={"name": "test2"}, @@ -9667,6 +11430,7 @@ def test_saved_state_module_search_uses_cached_owner_kind_for_register(monkeypat "tables": ["ConfigCASSave"], "query": "ПередЗаписью", "limit": 10, + "include_storage": True, } ) @@ -9697,7 +11461,7 @@ def test_saved_state_modules_search_treats_empty_query_as_no_filter(monkeypatch: monkeypatch.setattr(adapter_server, "saved_state_module_search_row", fake_search_row) result = adapter_server.metadata_saved_state_modules_search( - {"base_id": "upo_test", "tables": ["ConfigCASSave"], "query": "", "limit": 5} + {"base_id": "upo_test", "tables": ["ConfigCASSave"], "query": "", "limit": 5, "include_storage": True} ) assert result["status"] == "ok" @@ -9706,6 +11470,48 @@ def test_saved_state_modules_search_treats_empty_query_as_no_filter(monkeypatch: assert seen_payloads[0]["query"] is None +def test_saved_state_modules_search_accepts_semantic_layer(monkeypatch: pytest.MonkeyPatch) -> None: + seen_tables: list[str] = [] + + def fake_storage_files_list(payload: dict[str, Any]) -> dict[str, Any]: + seen_tables.append(payload["table"]) + return {"status": "ok", "files": []} + + monkeypatch.setattr(adapter_server, "storage_files_list", fake_storage_files_list) + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_modules_owner_guid_from_selector", + lambda *_args, **_kwargs: ("owner-guid", {"status": "resolved"}), + ) + + result = adapter_server.metadata_saved_state_modules_search( + { + "base_id": "upo_test", + "layer": "base_saved_state", + "ref": "CommonModule.Интеграция", + "query": "Отправить", + } + ) + + assert result["status"] == "ok" + assert result["source"] == {"kind": "saved_state"} + assert result["query"]["layer"] == "base_saved_state" + assert seen_tables == ["ConfigSave"] + + +def test_saved_state_modules_search_rejects_layer_and_tables_together() -> None: + result = adapter_server.metadata_saved_state_modules_search( + { + "base_id": "upo_test", + "layer": "extension_saved_state", + "tables": ["ConfigCASSave"], + } + ) + + assert result["status"] == "invalid_argument" + assert result["argument"] == "tables" + + def test_saved_state_modules_search_scopes_extension_to_cassave_prefix(monkeypatch: pytest.MonkeyPatch) -> None: extension_guid = "fb26cf42-7609-11f1-828f-005056b0d483" seen_file_list_payloads = [] @@ -9736,7 +11542,14 @@ def test_saved_state_modules_search_scopes_extension_to_cassave_prefix(monkeypat monkeypatch.setattr(adapter_server, "saved_state_module_search_row", fake_search_row) result = adapter_server.metadata_saved_state_modules_search( - {"base_id": "upo_test", "tables": ["ConfigCASSave", "ConfigSave"], "extension": "test2", "query": "", "limit": 5} + { + "base_id": "upo_test", + "tables": ["ConfigCASSave", "ConfigSave"], + "extension": "test2", + "query": "", + "limit": 5, + "include_storage": True, + } ) assert result["status"] == "ok" @@ -9778,7 +11591,13 @@ def test_saved_state_forms_search_scopes_extension_to_cassave_prefix(monkeypatch monkeypatch.setattr(adapter_server, "saved_state_form_search_row", fake_form_row) result = adapter_server.metadata_saved_state_forms_search( - {"base_id": "upo_test", "tables": ["ConfigCASSave", "ConfigSave"], "extension": "test2", "limit": 5} + { + "base_id": "upo_test", + "tables": ["ConfigCASSave", "ConfigSave"], + "extension": "test2", + "limit": 5, + "include_storage": True, + } ) assert result["status"] == "ok" @@ -10306,6 +12125,53 @@ def test_code_search_exposes_scan_and_owner_counts(monkeypatch: pytest.MonkeyPat assert result["items"][0]["origin"]["write_surface"] == "requires_owner_resolution" +def test_code_search_uses_effective_working_view_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + seen_payload: dict[str, Any] = {} + + def fake_search_modules(payload: dict[str, Any]) -> dict[str, Any]: + seen_payload.update(payload) + return {"status": "not_found", "source": {"kind": "logical"}, "matches": [], "counts": {}, "diagnostics": {}} + + monkeypatch.setattr(adapter_server, "search_modules", fake_search_modules) + + result = adapter_server.code_search({"base_id": "upo_test", "query": "Тест"}) + + assert seen_payload["state"] == "working" + assert result["configuration_view"]["name"] == "effective_working" + assert "not claimed to be the currently applied runtime" in result["configuration_view"]["meaning"] + + +def test_code_search_accepts_runtime_applied_configuration_view(monkeypatch: pytest.MonkeyPatch) -> None: + seen_payload: dict[str, Any] = {} + + def fake_search_modules(payload: dict[str, Any]) -> dict[str, Any]: + seen_payload.update(payload) + return {"status": "not_found", "source": {"kind": "logical"}, "matches": [], "counts": {}, "diagnostics": {}} + + monkeypatch.setattr(adapter_server, "search_modules", fake_search_modules) + + result = adapter_server.code_search( + {"base_id": "upo_test", "query": "Тест", "configuration_view": "runtime_applied"} + ) + + assert seen_payload["state"] == "active" + assert result["configuration_view"]["name"] == "runtime_applied" + + +def test_code_read_rejects_conflicting_configuration_view(monkeypatch: pytest.MonkeyPatch) -> None: + result = adapter_server.code_read( + { + "base_id": "upo_test", + "configuration_view": "runtime_applied", + "state": "working", + "module_id": "Config:test.0", + } + ) + + assert result["status"] == "invalid_argument" + assert result["argument"] == "configuration_view" + + def test_code_search_accepts_module_ordinal_without_tuple_response(monkeypatch: pytest.MonkeyPatch) -> None: seen_payload: dict[str, Any] = {} @@ -10820,7 +12686,7 @@ def test_code_search_accepts_public_ref_selector(monkeypatch: pytest.MonkeyPatch {"base_id": "upo_test", "query": "ЦелеваяПроцедура", "ref": "Обработка.ТестоваяОбработка"}, ) - assert result["status"] == "ok" + assert result["status"] == "not_found" assert seen_payloads[0]["kind"] == "DataProcessor" assert seen_payloads[0]["name"] == "ТестоваяОбработка" @@ -10843,7 +12709,7 @@ def test_code_search_direct_call_accepts_public_ref_selector(monkeypatch: pytest {"base_id": "upo_test", "query": "ЦелеваяПроцедура", "ref": "Обработка.ТестовыйОбъект"} ) - assert result["status"] == "ok" + assert result["status"] == "not_found" assert seen_payloads[0]["kind"] == "DataProcessor" assert seen_payloads[0]["name"] == "ТестовыйОбъект" @@ -10959,12 +12825,121 @@ def test_attributes_cancel_marks_running_sections_cancelled() -> None: assert result["status"] == "cancelled" assert result["current_step"] == "cancelled" + assert result["diagnostics"]["termination"] == "hard_process" assert result["partial_result"]["sections"]["semantic"] == "cancelled" assert result["partial_result"]["sections"]["type_resolution"] == "cancelled" assert result["progress"]["running_steps"] == [] adapter_server.ADAPTER_JOBS.pop(job_id, None) +def test_adapter_jobs_persist_in_local_sqlite_and_drop_credentials(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + state_db = tmp_path / "adapter-state.sqlite" + legacy_json = tmp_path / "adapter-jobs.json" + monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(state_db)) + monkeypatch.setenv("ONEC_ADAPTER_JOB_STORE", str(legacy_json)) + original_jobs = dict(adapter_server.ADAPTER_JOBS) + original_loaded = adapter_server.ADAPTER_JOB_STORE_LOADED + now = adapter_server.adapter_now() + + try: + adapter_server.ADAPTER_JOBS.clear() + adapter_server.ADAPTER_JOB_STORE_LOADED = True + adapter_server.ADAPTER_JOBS.update( + { + "queued-job": { + "schema": "onec_adapter_job.v1", + "status": "queued", + "job_id": "queued-job", + "method": "metadata.object.full", + "created_at": now, + "updated_at": now, + }, + "done-job": { + "schema": "onec_adapter_job.v1", + "status": "done", + "job_id": "done-job", + "method": "metadata.object.get", + "created_at": now, + "updated_at": now, + "result": {"status": "ok", "password": "must-not-be-stored", "password_state": "set"}, + }, + } + ) + + adapter_server.adapter_save_jobs_to_store() + adapter_server.ADAPTER_JOBS.clear() + adapter_server.ADAPTER_JOB_STORE_LOADED = False + adapter_server.adapter_load_jobs_from_store() + + assert state_db.is_file() + assert not legacy_json.exists() + assert adapter_server.ADAPTER_JOBS["queued-job"]["status"] == "error" + assert adapter_server.ADAPTER_JOBS["queued-job"]["error"] == "adapter_restarted" + assert adapter_server.ADAPTER_JOBS["done-job"]["result"]["status"] == "ok" + assert adapter_server.ADAPTER_JOBS["done-job"]["result"]["password_state"] == "set" + assert "password" not in adapter_server.ADAPTER_JOBS["done-job"]["result"] + with sqlite3.connect(state_db) as conn: + assert conn.execute("SELECT COUNT(*) FROM adapter_jobs").fetchone()[0] == 2 + finally: + adapter_server.ADAPTER_JOBS.clear() + adapter_server.ADAPTER_JOBS.update(original_jobs) + adapter_server.ADAPTER_JOB_STORE_LOADED = original_loaded + + +def test_adapter_background_job_runs_in_killable_process(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(tmp_path / "adapter-state.sqlite")) + monkeypatch.setenv("ONEC_ADAPTER_JOB_PROCESS_ISOLATION", "true") + monkeypatch.setenv("ONEC_ADAPTER_JOB_TIMEOUT_SECONDS", "20") + + accepted = adapter_server.adapter_start_job({"method": "help.methods", "payload": {}, "timeout_seconds": 20}) + assert accepted["status"] == "accepted" + assert accepted["timeout_seconds"] == 20 + job_id = accepted["job_id"] + deadline = time.time() + 20 + job: dict[str, Any] = {} + try: + while time.time() < deadline: + with adapter_server.ADAPTER_JOB_LOCK: + job = dict(adapter_server.ADAPTER_JOBS.get(job_id) or {}) + if job.get("status") in {"done", "error", "cancelled"}: + break + time.sleep(0.05) + + assert job["status"] == "done" + assert job["worker"]["mode"] == "process" + assert int(job["worker"]["pid"]) > 0 + assert job["result"]["schema"] == "onec_adapter_methods.v1" + assert job["result"]["count"] == len(adapter_server.METHODS) + finally: + with adapter_server.ADAPTER_JOB_LOCK: + adapter_server.ADAPTER_JOBS.pop(job_id, None) + adapter_server.adapter_save_jobs_to_store() + + +def test_adapter_job_process_timeout_terminates_child(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(tmp_path / "adapter-state.sqlite")) + job_id = "isolated-timeout" + now = adapter_server.adapter_now() + adapter_server.ADAPTER_JOBS[job_id] = { + "schema": "onec_adapter_job.v1", + "status": "running", + "job_id": job_id, + "method": "help.methods", + "created_at": now, + "started_at": now, + "updated_at": now, + } + try: + adapter_server.adapter_run_job_in_process(job_id, "help.methods", {}, 0.001) + + job = adapter_server.ADAPTER_JOBS[job_id] + assert job["status"] == "error" + assert job["error"] == "job_timeout" + assert "terminated" in job["diagnostics"]["message"] + finally: + adapter_server.ADAPTER_JOBS.pop(job_id, None) + + def test_attributes_cache_requires_explicit_use_cache(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("ONEC_ADAPTER_CACHE_DB", str(tmp_path / "cache.sqlite")) config = {"server": "sql-host", "database": "upo_test", "user": "sa", "password": "secret"} @@ -11793,6 +13768,25 @@ def test_mcp_onec_request_examples_are_generic_and_selector_oriented() -> None: assert "metadata.form.command_button.verify" in schema["properties"]["method"]["description"] assert "code.write" in onec_request["description"] assert "ref, kind/name/guid, or object_type/object_name/object_guid" in onec_request["description"] + + examples = { + example["method"]: example["payload"] + for example in schema.get("examples") or [] + if isinstance(example, dict) and isinstance(example.get("payload"), dict) + } + prepare = examples["metadata.saved_state.prepare"] + assert prepare["layer"] == "extension_saved_state" + assert "ref" in prepare + assert not {"target_table", "table", "file_name"} & prepare.keys() + diff = examples["metadata.saved_state.diff"] + assert "ref" in diff + assert "module_ordinal" in diff + assert not {"target_table", "table", "file_name", "module_ref"} & diff.keys() + status = examples["metadata.saved_state.status"] + assert status["layer"] == "extension_saved_state" + assert not {"target_table", "table"} & status.keys() + changes = examples["metadata.saved_state.changes.list"] + assert changes["layer"] == "extension_saved_state" payload_description = schema["properties"]["payload"]["description"] assert "ref" in payload_description assert "kind/name/guid" in payload_description @@ -11896,6 +13890,7 @@ def test_mcp_adapter_contract_script_checks_selector_guidance() -> None: assert report["checks"]["adapter_selector_capability_descriptor"] is True assert report["checks"]["adapter_definition_read_selector_public_ref"] is True assert report["checks"]["adapter_module_read_selector_public_ref"] is True + assert report["checks"]["agent_facing_object_methods_name_first"] is True assert report["checks"]["mcp_selector_schema_template"] is True assert report["checks"]["mcp_tool_selector_guidance"] is True assert report["checks"]["selector_chain_working_state"] is True @@ -11936,6 +13931,7 @@ def test_mcp_adapter_contract_selector_issue_codes_flip_a_check() -> None: "adapter_help_selector_capabilities_missing", "adapter_definition_read_selector_public_ref_missing", "adapter_module_read_selector_public_ref_missing", + "agent_facing_object_method_not_name_first", "adapter_contract_version_missing", "mcp_contract_version_mismatch", "adapter_help_contract_version_missing", @@ -12718,6 +14714,8 @@ def test_metadata_write_capabilities_reports_saved_state_rule() -> None: assert result["status"] == "ok" assert result["default_write_layer"] == "save" assert result["write_capabilities"]["bsl_modules"]["agent_method"] == "code.write" + assert result["write_capabilities"]["scheduled_jobs"]["status"] == "partial_saved_state" + assert result["write_capabilities"]["scheduled_jobs"]["agent_method"] == "metadata.write" assert "web_http_services" in result["unsupported_summary"] @@ -17570,6 +19568,126 @@ def test_form_element_write_rejects_active_config_table() -> None: assert result["argument"] == "table" +def test_form_element_write_keeps_owner_guid_out_of_form_guid_resolution(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + owner_guid = "22222222-2222-2222-2222-222222222222" + + def fake_resolve(payload: dict[str, Any]) -> dict[str, Any]: + seen["resolve_payload"] = payload + return { + "schema": "onec_form_write_target_resolution.v1", + "status": "not_found", + "base_id": payload["base_id"], + "diagnostics": {"message": "test stop after selector normalization"}, + } + + monkeypatch.setattr(adapter_server, "metadata_form_write_target_resolve", fake_resolve) + + result = adapter_server.call_method( + "metadata.form.element.write", + { + "base_id": "upo_test", + "ref": "Обработки.ВторойОбмен", + "object_guid": owner_guid, + "form_name": "ФормаОбмена", + "element_name": "КнопкаВыполнить", + "property": "title", + "value": "Выполнить", + "allow_saved_state_write": True, + }, + ) + + assert result["status"] == "not_found" + assert seen["resolve_payload"]["kind"] == "DataProcessor" + assert seen["resolve_payload"]["name"] == "ВторойОбмен" + assert seen["resolve_payload"]["guid"] == owner_guid + assert seen["resolve_payload"]["form_name"] == "ФормаОбмена" + assert seen["resolve_payload"].get("form_guid") is None + + +def test_form_element_write_help_exposes_owner_form_and_write_guards() -> None: + methods = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + } + + planner = methods["metadata.form.element.write"] + apply = methods["metadata.form.element.write_apply"] + for method in (planner, apply): + assert method["selector_capabilities"]["accepts_ordinal"] is False + assert method["selector_capabilities"]["form_selector_arguments"] == ["form", "form_name", "form_guid"] + assert method["selector_capabilities"]["child_selector_arguments"] == [ + "element", + "element_name", + "command", + "attribute", + ] + assert method["input_schema"]["properties"]["object_guid"]["type"] == "string" + assert method["input_schema"]["properties"]["form_guid"]["type"] == "string" + assert planner["selector_capabilities"]["write_guards"] == ["allow_saved_state_write", "proposal_only"] + assert "allow_sql_saved_state_apply" in apply["selector_capabilities"]["write_guards"] + + +def test_form_target_move_resolves_nested_form_by_owner_before_decode(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + owner_guid = "22222222-2222-2222-2222-222222222222" + + def fake_resolve(payload: dict[str, Any]) -> dict[str, Any]: + seen["resolve_payload"] = payload + return { + "schema": "onec_form_write_target_resolution.v1", + "status": "not_found", + "base_id": payload["base_id"], + "diagnostics": {"message": "test stop after owner/form resolution"}, + } + + monkeypatch.setattr(adapter_server, "metadata_form_write_target_resolve", fake_resolve) + + result = adapter_server.call_method( + "metadata.form.target.move", + { + "base_id": "upo_test", + "ref": "Обработки.ВторойОбмен", + "object_guid": owner_guid, + "form_name": "ФормаОбмена", + "from_element": "КнопкаСтарая", + "to_element": "КнопкаНовая", + "allow_saved_state_write": True, + }, + ) + + assert result["status"] == "not_found" + assert seen["resolve_payload"]["kind"] == "DataProcessor" + assert seen["resolve_payload"]["name"] == "ВторойОбмен" + assert seen["resolve_payload"]["guid"] == owner_guid + assert seen["resolve_payload"]["form_name"] == "ФормаОбмена" + assert seen["resolve_payload"]["element"] == "КнопкаСтарая" + assert seen["resolve_payload"].get("form_guid") is None + + +def test_form_write_target_resolve_preserves_legacy_guid_only_form_selector(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_decode(payload: dict[str, Any]) -> dict[str, Any]: + seen["decode_payload"] = payload + return {"schema": "onec_form_decode.v1", "status": "not_found"} + + monkeypatch.setattr(adapter_server, "metadata_form_decode", fake_decode) + + result = adapter_server.metadata_form_write_target_resolve( + { + "base_id": "upo_test", + "table": "ConfigCASSave", + "guid": "legacy-form-guid", + "element": "Кнопка", + } + ) + + assert result["status"] == "not_found" + assert seen["decode_payload"]["form_guid"] == "legacy-form-guid" + assert "guid" not in seen["decode_payload"] + + def test_form_element_write_builds_saved_state_change_proposal(monkeypatch: pytest.MonkeyPatch) -> None: seen: dict[str, Any] = {} @@ -18600,6 +20718,24 @@ def test_metadata_write_rollback_uses_operation_backup(tmp_path: Path, monkeypat assert seen["rollback_payload"]["allow_sql_saved_state_rollback"] is True +def test_metadata_write_rollback_checks_explicit_gate_before_repository(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "repository_apply_gate", + lambda *_args, **_kwargs: pytest.fail("repository gate must not run before rollback opt-in"), + ) + + result = adapter_server.metadata_write_rollback( + { + "base_id": "upo_test", + "operation_id": "operation-id", + } + ) + + assert result["status"] == "invalid_argument" + assert result["argument"] == "allow_sql_saved_state_rollback" + + def test_metadata_write_active_form_target_requires_saved_state_prepare(monkeypatch: pytest.MonkeyPatch) -> None: seen: dict[str, Any] = {} @@ -18620,8 +20756,14 @@ def test_metadata_write_active_form_target_requires_saved_state_prepare(monkeypa assert result["status"] == "blocked" assert result["error"] == "saved_state_prepare_required" - assert result["prepared_target"] == {"table": "ConfigCASSave", "file_name": "form-guid.0"} + assert result["prepared_target"] == {"layer": "extension_saved_state"} assert result["next_resolution"]["method"] == "metadata.saved_state.prepare" + assert result["next_resolution"]["params"] == { + "base_id": "upo_test", + "layer": "extension_saved_state", + "mode": "plan", + "selector_required": "Pass a public 1C ref or kind/name selector.", + } assert seen["prepare"]["mode"] == "plan" assert seen["prepare"]["source_table"] == "ConfigCAS" assert seen["prepare"]["target_table"] == "ConfigCASSave" @@ -19180,7 +21322,17 @@ def test_metadata_write_plan_infers_form_target_from_canonical_path(monkeypatch: assert hint_payload["form"] == "ФормаДокумента" assert hint_payload["element"] == "КнопкаЗаписать" assert result["route"]["apply_payload_hint"]["ready_for_apply_method"] is False - assert result["route"]["apply_payload_hint"]["next_resolution"]["method"] == "metadata.form.write_target.resolve" + next_resolution = result["route"]["apply_payload_hint"]["next_resolution"] + assert next_resolution["method"] == "metadata.form.write_target.resolve" + assert next_resolution["params"] == { + "base_id": "upo_test", + "ref": "Document.Заказ", + "kind": "Document", + "name": "Заказ", + "form": "ФормаДокумента", + "element": "КнопкаЗаписать", + } + assert "payload" not in next_resolution def test_metadata_write_plan_infers_module_target_from_common_module_path(monkeypatch: pytest.MonkeyPatch) -> None: @@ -19225,7 +21377,16 @@ def test_metadata_write_plan_infers_module_target_from_common_module_path(monkey assert hint_payload["name"] == "Интеграция" assert hint_payload["routine_name"] == "Отправить" assert result["route"]["apply_payload_hint"]["ready_for_apply_method"] is False - assert result["route"]["apply_payload_hint"]["next_resolution"]["method"] == "metadata.saved_state.modules.search" + next_resolution = result["route"]["apply_payload_hint"]["next_resolution"] + assert next_resolution["method"] == "metadata.saved_state.modules.search" + assert next_resolution["params"] == { + "base_id": "upo_test", + "ref": "CommonModule.Интеграция", + "kind": "CommonModule", + "name": "Интеграция", + "query": "Отправить", + } + assert "payload" not in next_resolution def test_metadata_write_plan_infers_module_target_from_common_form_routine_path(monkeypatch: pytest.MonkeyPatch) -> None: @@ -19255,7 +21416,15 @@ def test_metadata_write_plan_infers_module_target_from_common_form_routine_path( assert hint_payload["name"] == "t_Форма" assert hint_payload["routine_name"] == "ЗаменаДомена" assert result["route"]["apply_payload_hint"]["ready_for_apply_method"] is False - assert result["route"]["apply_payload_hint"]["next_resolution"]["method"] == "metadata.saved_state.modules.search" + next_resolution = result["route"]["apply_payload_hint"]["next_resolution"] + assert next_resolution["method"] == "metadata.saved_state.modules.search" + assert next_resolution["params"] == { + "base_id": "upo_test", + "ref": "CommonForm.t_Форма", + "kind": "CommonForm", + "name": "t_Форма", + "query": "ЗаменаДомена", + } def test_metadata_write_plan_allows_concrete_module_saved_state_reference() -> None: @@ -19393,6 +21562,11 @@ def test_metadata_write_plan_blocks_container_module_ref_without_stream() -> Non assert result["allowed"] is True assert hint["ready_for_apply_method"] is False assert hint["next_resolution"]["method"] == "metadata.saved_state.modules.search" + assert hint["next_resolution"]["params"]["base_id"] == "upo_test" + assert hint["next_resolution"]["params"]["query"] == "ЗаменаДомена" + assert "module_ref" not in hint["next_resolution"]["params"] + assert "file_name" not in hint["next_resolution"]["params"] + assert "payload" not in hint["next_resolution"] assert "#stream:" not in hint["payload"]["module_ref"] @@ -19708,6 +21882,10 @@ def test_metadata_write_blocked_form_path_exposes_next_resolution() -> None: assert result["status"] == "blocked" assert result["target_kind"] == "form" assert result["next_resolution"]["method"] == adapter_server.FORM_WRITE_TARGET_RESOLVE_METHOD + assert result["next_resolution"]["params"]["ref"] == "Document.Заказ" + assert result["next_resolution"]["params"]["form"] == "ФормаДокумента" + assert result["next_resolution"]["params"]["element"] == "КнопкаЗаписать" + assert "payload" not in result["next_resolution"] assert result["apply_payload_hint"]["ready_for_apply_method"] is False assert result["apply_payload_hint"]["payload"]["form"] == "ФормаДокумента" assert result["apply_payload_hint"]["payload"]["element"] == "КнопкаЗаписать" @@ -19725,6 +21903,9 @@ def test_metadata_write_blocked_module_path_exposes_next_resolution() -> None: assert result["status"] == "blocked" assert result["target_kind"] == "module" assert result["next_resolution"]["method"] == adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD + assert result["next_resolution"]["params"]["ref"] == "CommonModule.Интеграция" + assert result["next_resolution"]["params"]["query"] == "Отправить" + assert "payload" not in result["next_resolution"] assert result["apply_payload_hint"]["ready_for_apply_method"] is False assert result["apply_payload_hint"]["payload"]["kind"] == "CommonModule" assert result["apply_payload_hint"]["payload"]["name"] == "Интеграция" @@ -20379,6 +22560,67 @@ def test_code_write_facade_defaults_to_save_first_and_hides_storage(monkeypatch: } +def test_code_write_normalizes_public_owner_ref_before_routing(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_metadata_write(payload: dict[str, Any]) -> dict[str, Any]: + seen["payload"] = payload + return { + "schema": "onec_metadata_write.v1", + "method": "metadata.write", + "status": "planned", + "execution_mode": "plan", + "target_kind": "module", + "base_id": payload["base_id"], + "routed_method": "metadata.module.write_apply", + "result": {"status": "planned", "applied": False}, + } + + monkeypatch.setattr(adapter_server, "metadata_write", fake_metadata_write) + + result = adapter_server.call_method( + "code.write", + { + "base_id": "upo_test", + "ref": "Обработки.ОбменДанными", + "routine_name": "ВыполнитьОбмен", + "routine_text": "Процедура ВыполнитьОбмен()\nКонецПроцедуры\n", + "execution_mode": "plan", + }, + ) + + assert result["status"] == "planned" + assert seen["payload"]["target"] == { + "kind": "module", + "object_type": "DataProcessor", + "object_name": "ОбменДанными", + "routine_name": "ВыполнитьОбмен", + } + assert result["target"]["object_type"] == "DataProcessor" + assert result["target"]["object_name"] == "ОбменДанными" + assert result["target"]["routine_name"] == "ВыполнитьОбмен" + + +def test_code_write_help_exposes_name_first_owner_and_write_guards() -> None: + method = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + }["code.write"] + + assert method["selector_capabilities"]["accepts_ordinal"] is False + assert method["selector_capabilities"]["accepts_module_ref"] is True + assert method["selector_capabilities"]["form_selector_arguments"] == ["form", "form_name"] + assert method["selector_capabilities"]["child_selector_arguments"] == ["routine_name"] + assert method["selector_capabilities"]["write_guards"] == [ + "save_first", + "allow_sql_saved_state_apply", + "repository_apply_gate", + "verified_saved_state_target", + ] + assert method["input_schema"]["properties"]["ref"]["type"] == "string" + assert method["input_schema"]["properties"]["object_name"]["type"] == "string" + + def test_code_write_records_write_history(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("ONEC_ADAPTER_CACHE_DB", str(tmp_path / "adapter-cache.sqlite")) monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: ({"server": "sql.example", "database": base_id}, None)) @@ -20763,6 +23005,83 @@ def test_form_write_matrix_build_routes_readable_title_to_effective_source(monke assert routed["property"]["write_path"] == "3.3.4.2.1" +def test_form_write_matrix_build_passes_owner_scope_only_to_file_resolution(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + owner_guid = "22222222-2222-2222-2222-222222222222" + + def fake_resolve_file(payload: dict[str, Any], **kwargs: Any) -> tuple[str, str]: + seen["resolve_payload"] = payload + return "ConfigCASSave", "resolved-form.0" + + def fake_decode(payload: dict[str, Any]) -> dict[str, Any]: + seen["decode_payload"] = payload + return { + "status": "ok", + "source": {"table": payload["table"], "file_name": payload["file_name"]}, + "form": {"name": "ФормаОбмена", "file_name": payload["file_name"]}, + "profile": {"items": [], "commands": [], "attributes": [], "tables": [], "command_bars": []}, + } + + monkeypatch.setattr(adapter_server, "write_learning_resolve_file", fake_resolve_file) + monkeypatch.setattr(adapter_server, "metadata_form_decode", fake_decode) + monkeypatch.setattr( + adapter_server, + "read_storage_file_bytes", + lambda *args, **kwargs: (None, {}, {"status": "not_found"}), + ) + + result = adapter_server.call_method( + "metadata.form.write_matrix.build", + { + "base_id": "upo_test", + "ref": "Обработки.ВторойОбмен", + "object_guid": owner_guid, + "form_name": "ФормаОбмена", + }, + ) + + assert result["status"] == "ok" + assert seen["resolve_payload"]["kind"] == "DataProcessor" + assert seen["resolve_payload"]["name"] == "ВторойОбмен" + assert seen["resolve_payload"]["guid"] == owner_guid + assert seen["resolve_payload"]["form_name"] == "ФормаОбмена" + assert seen["decode_payload"]["file_name"] == "resolved-form.0" + for key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid"): + assert key not in seen["decode_payload"] + + +def test_form_target_move_and_write_matrix_help_expose_name_first_hierarchy() -> None: + methods = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + } + + move = methods["metadata.form.target.move"] + assert move["selector_capabilities"]["form_selector_arguments"] == ["form", "form_name", "form_guid"] + assert move["selector_capabilities"]["child_selector_arguments"] == [ + "from_element", + "to_element", + "with_element", + "after_element", + ] + assert "allow_sql_saved_state_apply" in move["selector_capabilities"]["write_guards"] + + matrix_build = methods["metadata.form.write_matrix.build"] + matrix_smoke = methods["metadata.form.write_matrix.smoke"] + assert matrix_build["selector_capabilities"]["read_only"] is True + assert matrix_smoke["selector_capabilities"]["read_only"] is False + assert matrix_smoke["selector_capabilities"]["write_guards"] == [ + "allow_sql_saved_state_apply", + "allow_sql_saved_state_rollback", + "apply_and_rollback_only", + ] + for method in (matrix_build, matrix_smoke): + assert method["selector_capabilities"]["accepts_ordinal"] is False + assert method["selector_capabilities"]["form_selector_arguments"] == ["form", "form_name", "form_guid"] + assert method["input_schema"]["properties"]["object_guid"]["type"] == "string" + assert method["input_schema"]["properties"]["form_guid"]["type"] == "string" + + def test_form_write_matrix_smoke_runs_safe_candidates_with_rollback(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( adapter_server, @@ -20819,6 +23138,72 @@ def test_form_write_matrix_smoke_runs_safe_candidates_with_rollback(monkeypatch: assert seen["payload"]["edits"] == [{"property": "title", "value": "SMOKE"}] +def test_write_learning_capture_uses_owner_scope_only_for_form_resolution( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: dict[str, Any] = {} + owner_guid = "22222222-2222-2222-2222-222222222222" + monkeypatch.setenv("ONEC_ADAPTER_WRITE_LEARNING_DIR", str(tmp_path)) + + def fake_resolve_file(payload: dict[str, Any], **kwargs: Any) -> tuple[str, str]: + seen["resolve_payload"] = payload + return "ConfigCASSave", "resolved-form.0" + + def fake_decode(payload: dict[str, Any]) -> dict[str, Any]: + seen["decode_payload"] = payload + return { + "status": "ok", + "source": {"table": payload["table"], "file_name": payload["file_name"]}, + "form": {"name": "ФормаОбмена", "file_name": payload["file_name"]}, + "profile": {"items": [], "commands": [], "attributes": [], "tables": [], "command_bars": []}, + } + + monkeypatch.setattr(adapter_server, "write_learning_resolve_file", fake_resolve_file) + monkeypatch.setattr(adapter_server, "metadata_form_decode", fake_decode) + monkeypatch.setattr( + adapter_server, + "read_storage_file_bytes", + lambda *args, **kwargs: (b"saved-form", {"server": "test", "database": "upo_test"}, None), + ) + + result = adapter_server.call_method( + "metadata.write_learning.capture_before", + { + "base_id": "upo_test", + "learning_id": "owner-form-scope", + "ref": "Обработки.ВторойОбмен", + "object_guid": owner_guid, + "form_name": "ФормаОбмена", + "element_name": "КнопкаВыполнить", + }, + ) + + assert result["status"] == "ok" + assert Path(result["path"]).is_file() + assert seen["resolve_payload"]["kind"] == "DataProcessor" + assert seen["resolve_payload"]["name"] == "ВторойОбмен" + assert seen["resolve_payload"]["guid"] == owner_guid + assert seen["resolve_payload"]["form_name"] == "ФормаОбмена" + for key in ("ref", "kind", "name", "guid", "object_type", "object_name", "object_guid"): + assert key not in seen["decode_payload"] + + +def test_write_learning_capture_help_exposes_owner_and_form_selectors() -> None: + methods = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + } + + for method_name in ("metadata.write_learning.capture_before", "metadata.write_learning.capture_after"): + method = methods[method_name] + assert method["selector_capabilities"]["accepts_ordinal"] is False + assert method["selector_capabilities"]["form_selector_arguments"] == ["form", "form_name", "form_guid"] + assert method["selector_capabilities"]["write_guards"] == ["local_artifact_only", "no_sql_write"] + assert method["input_schema"]["properties"]["object_guid"]["type"] == "string" + assert method["input_schema"]["properties"]["form_guid"]["type"] == "string" + + def test_write_learning_diff_and_infer_rule(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("ONEC_ADAPTER_WRITE_LEARNING_DIR", str(tmp_path)) learning_id = "case1" @@ -21022,7 +23407,12 @@ def test_saved_state_forms_search_indexes_form_commands(monkeypatch: pytest.Monk ) result = adapter_server.metadata_saved_state_forms_search( - {"base_id": "upo_test", "tables": ["ConfigCASSave"], "element": "КомандаПример1"} + { + "base_id": "upo_test", + "tables": ["ConfigCASSave"], + "element": "КомандаПример1", + "include_storage": True, + } ) assert result["status"] == "ok" @@ -21059,7 +23449,9 @@ def test_saved_state_forms_search_scans_base_form_rows_without_suffix(monkeypatc monkeypatch.setattr(adapter_server, "metadata_form_decode", fake_decode) - result = adapter_server.metadata_saved_state_forms_search({"base_id": "upo_test", "tables": ["ConfigSave"]}) + result = adapter_server.metadata_saved_state_forms_search( + {"base_id": "upo_test", "tables": ["ConfigSave"], "include_storage": True} + ) assert seen == ["base-form-guid"] assert result["counts"]["forms"] == 1 @@ -21876,6 +24268,7 @@ def test_saved_state_modules_search_returns_stream_refs(monkeypatch: pytest.Monk "tables": ["ConfigCASSave"], "query": "Команда1", "limit": 10, + "include_storage": True, } ) @@ -21930,6 +24323,7 @@ def test_saved_state_modules_search_returns_form_embedded_module_container(monke "tables": ["ConfigCASSave"], "query": "ЗаменаДомена", "limit": 10, + "include_storage": True, } ) @@ -21956,6 +24350,61 @@ def test_saved_state_modules_search_returns_form_embedded_module_container(monke assert write_target["form_guid"] == "form-guid" +def test_public_saved_state_module_search_row_is_name_first() -> None: + public = adapter_server.public_saved_state_modules_search_row( + { + "table": "ConfigCASSave", + "file_name": "extension-guid__form-guid.0", + "identity": {"owner_guid": "extension-guid", "module_guid": "form-guid"}, + "owner": { + "status": "resolved", + "kind": "Catalog", + "name": "test2", + "synonym": "Test2", + "guid": "owner-guid", + "source": "saved_state_descriptor", + }, + "form": {"name": "t_Форма", "guid": "form-guid", "source": "saved_state_descriptor"}, + "module": {"kind": "form_module", "name": "Модуль формы"}, + "qualified_name": "test2.t_Форма.Модуль формы", + "payload": {"sha1": "payload-sha1"}, + "streams": [ + { + "module_ref": "ConfigCASSave:extension-guid__form-guid.0", + "module_path": "2", + "preview": "Процедура Команда()\nКонецПроцедуры", + "match": {"query": "Команда", "in_text": True, "in_file_name": False}, + "write_plan_target": { + "module_ref": "ConfigCASSave:extension-guid__form-guid.0", + "file_name": "extension-guid__form-guid.0", + }, + } + ], + } + ) + + assert public is not None + assert public["qualified_name"] == "test2.t_Форма.Модуль формы" + assert public["selector"] == { + "ref": "Catalog.test2", + "form": "t_Форма", + "module": "Модуль формы", + "qualified_name": "test2.t_Форма.Модуль формы", + } + assert public["streams"][0]["match"] == {"query": "Команда", "in_text": True, "in_name": False} + for storage_marker in ( + "ConfigCASSave", + "extension-guid", + "form-guid", + "owner-guid", + "module_ref", + "file_name", + "write_plan_target", + "sha1", + ): + assert storage_marker not in str(public) + + def test_modules_search_strips_form_embedded_module_tail(monkeypatch: pytest.MonkeyPatch) -> None: form_text = '{"meta","data","&НаКлиенте\r\nПроцедура ЗаменаДомена(Команда)\r\n\t// Отредактировано адаптером\r\nКонецПроцедуры\r\n\r\n///----\\",\r\n{4,0,0,0,{#base64:AAAA}}"}' stored = compress_payload(form_text.encode("utf-8-sig"), "raw_deflate") @@ -22022,6 +24471,7 @@ def test_saved_state_modules_search_scans_nonzero_base_module_parts(monkeypatch: "tables": ["ConfigSave"], "query": "Команда3", "limit": 10, + "include_storage": True, } ) @@ -22175,9 +24625,10 @@ def test_saved_state_modules_search_resolves_object_name_to_owner_guid(monkeypat "base_id": "upo_test", "tables": ["ConfigCASSave"], "object_type": "Catalog", - "object_name": "Номенклатура", - "query": "ПередЗаписью", - "limit": 10, + "object_name": "Номенклатура", + "query": "ПередЗаписью", + "limit": 10, + "include_storage": True, }, ) @@ -22190,6 +24641,61 @@ def test_saved_state_modules_search_resolves_object_name_to_owner_guid(monkeypat assert result["modules"][0]["streams"][0]["write_plan_target"]["module_ref"] == "ConfigCASSave:owner-guid__module-guid.0#stream:0" +def test_saved_state_modules_search_normalizes_public_owner_ref(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_cache_lookup(base_id: str, kind: str, name: str) -> dict[str, Any]: + seen["cache_lookup"] = {"base_id": base_id, "kind": kind, "name": name} + return { + "guid": "owner-guid", + "kind": kind, + "kind_ru": "Справочник", + "public_kind": "catalog", + "name": name, + "source": "base", + } + + def fake_files(payload: dict[str, Any]) -> dict[str, Any]: + seen["files_payload"] = payload + return {"status": "ok", "files": []} + + monkeypatch.setattr(adapter_server, "metadata_cache_lookup_row", fake_cache_lookup) + monkeypatch.setattr(adapter_server, "storage_files_list", fake_files) + + result = adapter_server.call_method( + adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD, + { + "base_id": "upo_test", + "ref": "Справочники.Номенклатура", + "tables": ["ConfigCASSave"], + }, + ) + + assert result["status"] == "ok" + assert seen["cache_lookup"] == { + "base_id": "upo_test", + "kind": "Catalog", + "name": "Номенклатура", + } + assert seen["files_payload"]["prefix"] == "owner-guid" + assert result["query"]["object_type"] == "Catalog" + assert result["query"]["object_name"] == "Номенклатура" + + +def test_saved_state_modules_search_help_exposes_name_first_owner_selector() -> None: + method = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + }[adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD] + + assert method["selector_capabilities"]["accepts_ordinal"] is False + assert method["selector_capabilities"]["allows_global_search"] is True + assert method["selector_capabilities"]["read_only"] is True + assert method["selector_capabilities"]["module_selector_arguments"] == ["file_name", "stream_index"] + assert method["input_schema"]["properties"]["ref"]["type"] == "string" + assert method["input_schema"]["properties"]["object_name"]["type"] == "string" + + def test_saved_state_modules_search_resolves_common_form_name_to_saved_file(monkeypatch: pytest.MonkeyPatch) -> None: form_text = '{"meta","data","&НаКлиенте\r\nПроцедура Cmd(Команда)\r\nКонецПроцедуры\r\n"}' stored = compress_payload(form_text.encode("utf-8-sig"), "raw_deflate") @@ -22246,6 +24752,7 @@ def test_saved_state_modules_search_resolves_common_form_name_to_saved_file(monk "object_name": "TestForm", "query": "Cmd", "limit": 10, + "include_storage": True, } ) @@ -22279,7 +24786,13 @@ def test_saved_state_prepare_plans_active_to_save_copy(monkeypatch: pytest.Monke monkeypatch.setattr(adapter_server, "saved_state_copy_row_details", fake_row_details) result = adapter_server.metadata_saved_state_prepare( - {"base_id": "upo_test", "target_table": "ConfigCASSave", "object_type": "CommonForm", "object_name": "t_Форма"} + { + "base_id": "upo_test", + "target_table": "ConfigCASSave", + "object_type": "CommonForm", + "object_name": "t_Форма", + "include_storage": True, + } ) assert seen["source_table"] == "ConfigCAS" @@ -22291,6 +24804,77 @@ def test_saved_state_prepare_plans_active_to_save_copy(monkeypatch: pytest.Monke assert result["write_mode"]["sql_write_performed"] is False +def test_saved_state_prepare_default_is_name_first_and_hides_storage(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, str] = {} + + def fake_file_names(payload: dict[str, Any], base_id: str, source_table: str, timeout_seconds: int): + seen["source_table"] = source_table + seen["kind"] = str(payload.get("kind") or "") + seen["name"] = str(payload.get("name") or "") + return ["owner__module.0"], {"guid": "owner", "kind": "CommonForm", "name": "t_Форма"}, None + + def fake_row_details(base_id: str, table: str, file_names: list[str], *, timeout_seconds: int = 30): + seen.setdefault("target_table", table) + rows = ( + [{"FileName": "owner__module.0", "PartNo": 0, "DataSize": 10, "BinaryBytes": 10, "BinarySHA1": "A" * 40}] + if table == "ConfigCAS" + else [] + ) + return rows, {"database": "upo_test"}, None + + monkeypatch.setattr(adapter_server, "saved_state_prepare_file_names", fake_file_names) + monkeypatch.setattr(adapter_server, "saved_state_copy_row_details", fake_row_details) + + result = adapter_server.metadata_saved_state_prepare( + { + "base_id": "upo_test", + "layer": "extension_saved_state", + "ref": "CommonForm.t_Форма", + } + ) + + assert seen == { + "source_table": "ConfigCAS", + "kind": "CommonForm", + "name": "t_Форма", + "target_table": "ConfigCAS", + } + assert result["status"] == "plan_ready" + assert result["source"] == { + "kind": "active_configuration", + "layer": "extension_configuration", + } + assert result["target"] == {"layer": "extension_saved_state"} + assert result["object"] == { + "kind": "CommonForm", + "name": "t_Форма", + "ref": "CommonForm.t_Форма", + } + assert result["counts"] == { + "selected_objects": 1, + "source_records": 1, + "existing_saved_records": 0, + "inserted_records": 0, + } + serialized = json.dumps(result, ensure_ascii=False) + for storage_marker in ("ConfigCASSave", "ConfigCAS", "owner__module.0", "BinarySHA1", "database", "file_names", "source_rows"): + assert storage_marker not in serialized + + +def test_saved_state_prepare_rejects_conflicting_semantic_and_storage_layers() -> None: + result = adapter_server.metadata_saved_state_prepare( + { + "base_id": "upo_test", + "layer": "base_saved_state", + "target_table": "ConfigCASSave", + "ref": "Catalog.Номенклатура", + } + ) + + assert result["status"] == "invalid_argument" + assert result["argument"] == "layer" + + def test_saved_state_prepare_resolves_extension_route_cache(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: ({"database": base_id, "server": "s"}, None)) monkeypatch.setattr( @@ -22312,7 +24896,20 @@ def test_saved_state_prepare_resolves_extension_route_cache(monkeypatch: pytest. ], ensure_ascii=False, ), - } + }, + { + "extension_name": "test2", + "extension_guid": "ext-guid", + "object_kind": "CommonForm", + "name": "tt_Форма3", + "guid": "other-form-guid", + "descriptor_cas_key": "other-descriptor", + "route_json": json.dumps({"table": "ConfigCAS", "file_name": "other-descriptor"}, ensure_ascii=False), + "manifest_entries_json": json.dumps( + [{"suffix": "", "cas_key": "other-descriptor"}], + ensure_ascii=False, + ), + }, ], ) monkeypatch.setattr( @@ -22332,6 +24929,7 @@ def test_saved_state_prepare_resolves_extension_route_cache(monkeypatch: pytest. "extension": "test2", "object_type": "CommonForm", "object_name": "t_Форма", + "include_storage": True, } ) @@ -22400,6 +24998,160 @@ def test_saved_state_diff_reports_prepare_needed_when_save_missing(monkeypatch: assert result["prepare_payload"]["target_table"] == "ConfigSave" +def test_saved_state_diff_resolves_named_1c_object_module_without_exposing_storage(monkeypatch: pytest.MonkeyPatch) -> None: + module_reads: list[dict[str, Any]] = [] + storage_reads: list[tuple[str, str]] = [] + + def fake_read_module(payload: dict[str, Any]) -> dict[str, Any]: + module_reads.append(payload) + assert payload["kind"] == "Catalog" + assert payload["name"] == "Номенклатура" + return { + "schema": "onec_module_read.v1", + "status": "ok", + "module_id": f"{payload['table']}:catalog-guid.2", + "source": {"kind": "live_sql", "table": payload["table"], "file_name": "catalog-guid.2"}, + "owner": { + "kind": "Catalog", + "name": "Номенклатура", + "synonym": "Номенклатура", + "guid": "catalog-guid", + }, + "module": {"kind": "object_module", "name": "Модуль объекта", "module_ordinal": 2}, + } + + def fake_read_storage_file_bytes(base_id: str, table: str, file_name: str, *, timeout_seconds: int = 30): + storage_reads.append((table, file_name)) + value = b'{1,"saved"}' if table == "ConfigSave" else b'{1,"active"}' + return value, {"database": "upo_test"}, None + + monkeypatch.setattr(adapter_server, "read_module", fake_read_module) + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", fake_read_storage_file_bytes) + + result = adapter_server.metadata_saved_state_diff( + { + "base_id": "upo_test", + "object_type": "Catalog", + "object_name": "Номенклатура", + "module_ordinal": 2, + } + ) + + assert [item["table"] for item in module_reads] == ["ConfigSave"] + assert storage_reads == [("ConfigSave", "catalog-guid.2"), ("Config", "catalog-guid.2")] + assert result["status"] == "changed" + assert result["resolved_by"] == "public_object_module_selector" + assert result["target"]["selector"]["ref"] == "Catalog.Номенклатура" + assert result["target"]["selector"]["module_ordinal"] == 2 + assert result["target"]["object"]["name"] == "Номенклатура" + assert "storage" not in result["target"] + assert result["source"] == { + "kind": "live_metadata", + "active": {"state": "active"}, + "saved": {"state": "saved"}, + } + + +def test_saved_state_diff_named_selector_returns_name_first_prepare_payload(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_read_module(payload: dict[str, Any]) -> dict[str, Any]: + if payload["table"] == "ConfigSave": + return {"status": "source_missing", "error": "source_missing"} + return { + "status": "ok", + "module_id": "Config:catalog-guid.1", + "source": {"kind": "live_sql", "table": "Config", "file_name": "catalog-guid.1"}, + "owner": {"kind": "Catalog", "name": "Номенклатура", "guid": "catalog-guid"}, + "module": {"kind": "manager_module", "name": "Модуль менеджера", "module_ordinal": 1}, + } + + def fake_read_storage_file_bytes(base_id: str, table: str, file_name: str, *, timeout_seconds: int = 30): + assert table == "ConfigSave" + return None, {"database": "upo_test"}, { + "status": "source_missing", + "error": "source_missing", + "diagnostics": {"message": "missing"}, + } + + monkeypatch.setattr(adapter_server, "read_module", fake_read_module) + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", fake_read_storage_file_bytes) + + result = adapter_server.metadata_saved_state_diff( + { + "base_id": "upo_test", + "ref": "Catalog.Номенклатура", + "module_ordinal": 1, + } + ) + + assert result["status"] == "not_found" + assert result["needs_prepare"] is True + assert result["prepare_payload"] == { + "method": "metadata.saved_state.prepare", + "base_id": "upo_test", + "layer": "base_saved_state", + "ref": "Catalog.Номенклатура", + "kind": "Catalog", + "name": "Номенклатура", + "module_ordinal": 1, + "mode": "plan", + } + assert "file_name" not in result["prepare_payload"] + + +def test_saved_state_diff_named_selector_refreshes_stale_empty_module_cache(monkeypatch: pytest.MonkeyPatch) -> None: + module_reads: list[tuple[str, bool]] = [] + + def fake_read_module(payload: dict[str, Any]) -> dict[str, Any]: + table = str(payload["table"]) + refresh_cache = bool(payload["refresh_cache"]) + module_reads.append((table, refresh_cache)) + if table == "ConfigSave" or not refresh_cache: + return {"status": "not_found", "error": "not_found"} + return { + "status": "ok", + "module_id": "Config:common-module-guid.0#stream:4", + "source": {"kind": "live_sql", "table": "Config", "file_name": "common-module-guid.0"}, + "owner": {"kind": "CommonModule", "name": "ОбщегоНазначения", "guid": "common-module-guid"}, + "module": { + "kind": "common_module", + "name": "common-module-guid.0#stream:4", + "module_ordinal": 1, + }, + } + + def fake_read_storage_file_bytes(base_id: str, table: str, file_name: str, *, timeout_seconds: int = 30): + assert table == "ConfigSave" + return None, {"database": "upo_test"}, { + "status": "source_missing", + "error": "source_missing", + } + + monkeypatch.setattr(adapter_server, "read_module", fake_read_module) + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", fake_read_storage_file_bytes) + + result = adapter_server.metadata_saved_state_diff( + { + "base_id": "upo_test", + "ref": "CommonModule.ОбщегоНазначения", + "module_ordinal": 1, + } + ) + + assert module_reads == [ + ("ConfigSave", False), + ("ConfigSave", True), + ("Config", False), + ("Config", True), + ] + assert result["status"] == "not_found" + assert result["needs_prepare"] is True + assert result["target"]["module"] == { + "kind": "common_module", + "name": "ОбщегоНазначения", + "module_ordinal": 1, + } + + def test_saved_state_status_classifies_pending_files(monkeypatch: pytest.MonkeyPatch) -> None: def fake_status_rows(base_id: str, table: str, *, prefix: str = "", limit: int = 500, timeout_seconds: int = 30): assert table == "ConfigCASSave" @@ -22419,7 +25171,7 @@ def test_saved_state_status_classifies_pending_files(monkeypatch: pytest.MonkeyP monkeypatch.setattr(adapter_server, "saved_state_status_rows", fake_status_rows) monkeypatch.setattr(adapter_server, "saved_state_copy_row_details", fake_row_details) - result = adapter_server.metadata_saved_state_status({"base_id": "upo_test", "table": "ConfigCASSave"}) + result = adapter_server.metadata_saved_state_status({"base_id": "upo_test", "table": "ConfigCASSave", "include_storage": True}) assert result["schema"] == "onec_saved_state_status.v1" assert result["status"] == "changed" @@ -22437,7 +25189,7 @@ def test_saved_state_status_reports_empty(monkeypatch: pytest.MonkeyPatch) -> No monkeypatch.setattr(adapter_server, "saved_state_status_rows", lambda *args, **kwargs: ([], {"database": "upo_test"}, None)) monkeypatch.setattr(adapter_server, "saved_state_copy_row_details", lambda *args, **kwargs: ([], {"database": "upo_test"}, None)) - result = adapter_server.metadata_saved_state_status({"base_id": "upo_test", "table": "ConfigSave"}) + result = adapter_server.metadata_saved_state_status({"base_id": "upo_test", "table": "ConfigSave", "include_storage": True}) assert result["status"] == "empty" assert result["current_state"] == {"source": "active", "activation_state": "active"} @@ -22445,6 +25197,66 @@ def test_saved_state_status_reports_empty(monkeypatch: pytest.MonkeyPatch) -> No assert result["files"] == [] +def test_saved_state_status_default_is_semantic_and_hides_storage(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, str] = {} + + def fake_status_rows(base_id: str, table: str, *, prefix: str = "", limit: int = 500, timeout_seconds: int = 30): + seen["saved_table"] = table + return [ + {"FileName": "internal-guid.0", "PartNo": 0, "DataSize": 1, "BinaryBytes": 1, "BinarySHA1": "b" * 40}, + ], {"database": "upo_test"}, None + + def fake_row_details(base_id: str, table: str, file_names: list[str], *, timeout_seconds: int = 30): + seen["active_table"] = table + return [ + {"FileName": "internal-guid.0", "PartNo": 0, "DataSize": 1, "BinaryBytes": 1, "BinarySHA1": "a" * 40}, + ], {"database": "upo_test"}, None + + monkeypatch.setattr(adapter_server, "saved_state_status_rows", fake_status_rows) + monkeypatch.setattr(adapter_server, "saved_state_copy_row_details", fake_row_details) + + result = adapter_server.metadata_saved_state_status( + {"base_id": "upo_test", "layer": "extension_saved_state"} + ) + + assert seen == {"saved_table": "ConfigCASSave", "active_table": "ConfigCAS"} + assert result["source"] == {"kind": "saved_state"} + assert result["query"]["layer"] == "extension_saved_state" + assert result["layer"] == { + "name": "extension_saved_state", + "active_source": "extension_configuration", + } + assert result["counts"] == { + "saved_records": 1, + "saved_objects": 1, + "active_records": 1, + "changed_objects": 1, + "unchanged_objects": 0, + "saved_only_objects": 0, + "changed_parts": 2, + } + serialized = json.dumps(result, ensure_ascii=False) + assert "ConfigCASSave" not in serialized + assert "ConfigCAS" not in serialized + assert "internal-guid.0" not in serialized + assert "BinarySHA1" not in serialized + assert "database" not in serialized + assert "files" not in result + + +def test_saved_state_status_rejects_conflicting_semantic_and_storage_layers() -> None: + result = adapter_server.metadata_saved_state_status( + { + "base_id": "upo_test", + "layer": "base_saved_state", + "table": "ConfigCASSave", + } + ) + + assert result["status"] == "invalid_argument" + assert result["argument"] == "layer" + + def test_saved_state_changes_list_aggregates_pending_files(monkeypatch: pytest.MonkeyPatch) -> None: def fake_status(payload: dict[str, Any]) -> dict[str, Any]: table = payload["table"] @@ -22481,7 +25293,7 @@ def test_saved_state_changes_list_aggregates_pending_files(monkeypatch: pytest.M monkeypatch.setattr(adapter_server, "metadata_saved_state_status", fake_status) - result = adapter_server.metadata_saved_state_changes_list({"base_id": "upo_test"}) + result = adapter_server.metadata_saved_state_changes_list({"base_id": "upo_test", "include_storage": True}) assert result["schema"] == "onec_saved_state_changes_list.v1" assert result["status"] == "changed" @@ -22530,8 +25342,251 @@ def test_saved_state_changes_list_can_include_context(monkeypatch: pytest.Monkey result = adapter_server.metadata_saved_state_changes_list({"base_id": "upo_test", "table": "ConfigCASSave", "include_context": True}) assert result["query"]["include_context"] is True - assert result["files"][0]["context"]["kind"] == "module" - assert result["files"][0]["context"]["presentation"] == "ОбщаяФорма.t_Форма.Модуль формы" + assert result["changes"][0]["context"]["kind"] == "module" + assert result["changes"][0]["context"]["presentation"] == "ОбщаяФорма.t_Форма.Модуль формы" + assert "files" not in result + assert "tables" not in result + + +def test_saved_state_changes_list_accepts_semantic_layer(monkeypatch: pytest.MonkeyPatch) -> None: + seen: list[dict[str, Any]] = [] + + def fake_status(payload: dict[str, Any]) -> dict[str, Any]: + seen.append(payload) + return { + "schema": "onec_saved_state_status.v1", + "method": "metadata.saved_state.status", + "status": "empty", + "counts": { + "saved_rows": 0, + "saved_files": 0, + "active_rows": 0, + "changed_files": 0, + "unchanged_files": 0, + "saved_only_files": 0, + "changed_rows": 0, + }, + "files": [], + "freshness": {"status": "live_sql_verified"}, + } + + monkeypatch.setattr(adapter_server, "metadata_saved_state_status", fake_status) + + result = adapter_server.metadata_saved_state_changes_list( + {"base_id": "upo_test", "layer": "extension_saved_state"} + ) + + assert result["query"]["layers"] == ["extension_saved_state"] + assert len(seen) == 1 + assert seen[0]["table"] == "ConfigCASSave" + assert seen[0]["include_storage"] is True + + +def test_saved_state_changes_list_rejects_conflicting_semantic_and_storage_layers() -> None: + result = adapter_server.metadata_saved_state_changes_list( + { + "base_id": "upo_test", + "layer": "base_saved_state", + "table": "ConfigCASSave", + } + ) + + assert result["status"] == "invalid_argument" + assert result["argument"] == "layer" + + +def test_saved_state_changes_list_default_is_name_first(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_status", + lambda payload: { + "schema": "onec_saved_state_status.v1", + "method": "metadata.saved_state.status", + "status": "changed", + "counts": {"changed_files": 1, "saved_only_files": 0, "unchanged_files": 0}, + "files": [ + { + "file_name": "extension-guid__form-guid.0", + "status": "changed", + "changed_parts": 2, + "saved_sha1": ["saved-sha1"], + "active_sha1": ["active-sha1"], + "diff_selector": { + "method": "metadata.saved_state.diff", + "base_id": payload["base_id"], + "table": payload["table"], + "file_name": "extension-guid__form-guid.0", + }, + } + ], + "freshness": {"status": "live_sql_verified"}, + }, + ) + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_change_context", + lambda **kwargs: { + "kind": "module", + "presentation": "test2.t_Форма.Модуль формы", + "owner": { + "status": "resolved", + "kind": "Catalog", + "name": "test2", + "guid": "owner-guid", + "source": "saved_state_descriptor", + }, + "form": {"name": "t_Форма", "guid": "form-guid"}, + "module": {"kind": "form_module", "name": "Модуль формы"}, + "streams": [ + { + "module_ref": "ConfigCASSave:extension-guid__form-guid.0", + "write_plan_target": {"file_name": "extension-guid__form-guid.0"}, + } + ], + "source": {"method": "metadata.saved_state.modules.search", "status": "resolved_by_file"}, + }, + ) + + result = adapter_server.metadata_saved_state_changes_list( + {"base_id": "upo_test", "table": "ConfigCASSave"} + ) + + assert result["source"] == {"kind": "saved_state"} + assert result["query"]["layers"] == ["extension_saved_state"] + assert result["query"]["context_enrichment"] is True + assert result["counts"] == { + "layers": 1, + "changes": 1, + "changed": 1, + "saved_only": 0, + "unchanged": 0, + "error_layers": 0, + } + assert result["changes"][0]["presentation"] == "test2.t_Форма.Модуль формы" + assert result["changes"][0]["selector"] == { + "ref": "Catalog.test2", + "form": "t_Форма", + "module": "Модуль формы", + "qualified_name": "test2.t_Форма.Модуль формы", + } + assert result["diagnostics"]["resolved_contexts"] == 1 + assert result["freshness"] == { + "source": "saved_state", + "status": "live_verified", + "verified": True, + } + assert result["layers"][0]["freshness"] == { + "source": "saved_state", + "status": "live_verified", + "verified": True, + } + assert result["layers"][0]["counts"] == { + "saved_records": 0, + "saved_objects": 0, + "active_records": 0, + "changed": 1, + "unchanged": 0, + "saved_only": 0, + "changed_parts": 0, + } + assert "tables" not in result["counts"] + assert "files" not in result["counts"] + for storage_marker in ( + "live_sql", + "ConfigCASSave", + "extension-guid", + "form-guid", + "owner-guid", + "file_name", + "module_ref", + "write_plan_target", + "saved_sha1", + "active_sha1", + "diff_selector", + ): + assert storage_marker not in str(result) + + +def test_saved_state_change_context_does_not_claim_generic_role_as_resolved( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(adapter_server, "saved_state_form_search_row", lambda **kwargs: {"form": {}}) + monkeypatch.setattr( + adapter_server, + "saved_state_module_search_row", + lambda **kwargs: { + "module": {"kind": "form_module", "name": "Модуль формы"}, + "qualified_name": "Модуль формы", + "streams": [], + }, + ) + + context = adapter_server.metadata_saved_state_change_context( + base_id="upo_test", + table="ConfigCASSave", + file_name="opaque.0", + timeout_seconds=30, + ) + + assert context is None + + +def test_saved_state_change_context_family_adds_form_name_to_module() -> None: + files = [ + { + "table": "ConfigCASSave", + "file_name": "extension-guid__form-guid", + "context": { + "kind": "module", + "presentation": "test2.Модуль формы", + "owner": {"kind": "Catalog", "name": "test2"}, + "module": {"kind": "form_module", "name": "Модуль формы"}, + }, + }, + { + "table": "ConfigCASSave", + "file_name": "extension-guid__form-guid.0", + "context": { + "kind": "form", + "presentation": "test2.t_Форма.Форма", + "owner": {"kind": "Catalog", "name": "test2"}, + "form": {"name": "t_Форма"}, + }, + }, + ] + + adapter_server.enrich_saved_state_change_context_families(files) + + assert files[0]["context"]["form"]["name"] == "t_Форма" + assert files[0]["context"]["presentation"] == "test2.t_Форма.Модуль формы" + + +def test_public_saved_state_unresolved_group_hides_file_identity() -> None: + public = adapter_server.public_saved_state_change_group( + { + "key": "file|ConfigCASSave|opaque-hash", + "kind": "file", + "presentation": "opaque-hash", + "counts": {"files": 1, "changed_files": 1, "saved_only_files": 0, "unchanged_files": 0}, + "files": [ + { + "table": "ConfigCASSave", + "file_name": "opaque-hash", + "status": "changed", + "changed_parts": 2, + } + ], + }, + 3, + ) + + assert public["key"] == "unresolved_change:3" + assert public["kind"] == "unresolved" + assert public["counts"] == {"changes": 1, "changed": 1, "saved_only": 0, "unchanged": 0} + assert "presentation" not in public + assert "selector" not in public + assert "opaque-hash" not in str(public) + assert "ConfigCASSave" not in str(public) def test_saved_state_changes_list_can_group_by_context(monkeypatch: pytest.MonkeyPatch) -> None: @@ -22579,7 +25634,9 @@ def test_saved_state_changes_list_can_group_by_context(monkeypatch: pytest.Monke }, ) - result = adapter_server.metadata_saved_state_changes_list({"base_id": "upo_test", "table": "ConfigCASSave", "group_by_context": True}) + result = adapter_server.metadata_saved_state_changes_list( + {"base_id": "upo_test", "table": "ConfigCASSave", "group_by_context": True, "include_storage": True} + ) assert result["query"]["group_by_context"] is True assert result["query"]["context_enrichment"] is True @@ -22650,7 +25707,12 @@ def test_saved_state_prepare_blocks_target_collision(monkeypatch: pytest.MonkeyP ) result = adapter_server.metadata_saved_state_prepare( - {"base_id": "upo_test", "target_table": "ConfigCASSave", "file_name": "owner__module.0"} + { + "base_id": "upo_test", + "target_table": "ConfigCASSave", + "file_name": "owner__module.0", + "include_storage": True, + } ) assert result["status"] == "blocked_target_collision" @@ -22799,8 +25861,15 @@ def test_metadata_write_active_module_ref_requires_saved_state_prepare(monkeypat assert result["status"] == "blocked" assert result["error"] == "saved_state_prepare_required" - assert result["prepared_module_ref"] == "ConfigCASSave:owner__module.0#stream:4" + assert result["prepared_target"] == {"layer": "extension_saved_state"} + assert "prepared_module_ref" not in result assert result["next_resolution"]["method"] == "metadata.saved_state.prepare" + assert result["next_resolution"]["params"] == { + "base_id": "upo_test", + "layer": "extension_saved_state", + "mode": "plan", + "selector_required": "Pass a public 1C ref or kind/name selector.", + } assert seen["prepare"]["mode"] == "plan" assert seen["prepare"]["source_table"] == "ConfigCAS" assert seen["prepare"]["target_table"] == "ConfigCASSave" @@ -22867,7 +25936,15 @@ def test_metadata_write_missing_module_target_exposes_prepare_plan(monkeypatch: assert result["status"] == "not_found" assert result["error"] == "module_target_not_resolved" assert result["next_resolution"]["method"] == "metadata.saved_state.prepare" - assert result["next_resolution"]["payload"]["target_table"] == "ConfigCASSave" + assert result["next_resolution"]["params"] == { + "base_id": "upo_test", + "layer": "extension_saved_state", + "ref": "CommonForm.t_Форма", + "kind": "CommonForm", + "name": "t_Форма", + "extension": "test2", + "mode": "plan", + } assert result["prepare_plan"]["status"] == "plan_ready" @@ -23326,6 +26403,43 @@ def test_changes_propose_supports_preserve_format_structural_swap(monkeypatch: p assert bytes.fromhex(result["encoded"]["payload_hex"]).decode("utf-8") == '{0, {48,"Б",{3,4}}, {99}, {48,"А",{1,2}}}' +def test_changes_propose_supports_verified_root_replacement(monkeypatch: pytest.MonkeyPatch) -> None: + from parser.payload import parse_brace_text + + original = b"{1,2,3}" + replacement = parse_brace_text("{1,2,3,4,5}") + monkeypatch.setattr( + adapter_server, + "read_storage_file_bytes", + lambda *args, **kwargs: (original, {"database": "upo_test"}, None), + ) + + result = adapter_server.changes_propose( + { + "base_id": "upo_test", + "source": {"table": "ConfigSave", "file_name": "scheduled-job.0"}, + "edits": [ + { + "replace_root": replacement, + "fields": ["week_days"], + "resized_collections": ["week_days"], + "old_counts": {"week_days": 1}, + "new_counts": {"week_days": 3}, + } + ], + "preserve_format": True, + "include_payload": True, + } + ) + + assert result["status"] == "accepted_for_review" + assert result["validation"]["status"] == "ok" + assert result["edits"][0]["mode"] == "structural_replace_root_preserve_prefix" + assert result["edits"][0]["old_counts"] == {"week_days": 1} + assert result["edits"][0]["new_counts"] == {"week_days": 3} + assert bytes.fromhex(result["encoded"]["payload_hex"]).decode("utf-8-sig") == "{1,2,3,4,5}" + + def test_changes_propose_repairs_mojibake_stream_before_replace(monkeypatch: pytest.MonkeyPatch) -> None: from parser.cas_payload import stream_header @@ -23361,6 +26475,64 @@ def test_changes_propose_repairs_mojibake_stream_before_replace(monkeypatch: pyt assert not result["edits"][0]["new_text_preview"].startswith("\ufeff") +@pytest.mark.parametrize( + ("annotation", "expected"), + [ + ("Перед", "insert_before"), + ("После", "insert_after"), + ("Вместо", "replace"), + ("ИзменениеИКонтроль", "replace_with_control"), + ], +) +def test_extension_action_is_decoded_from_bsl_annotation(annotation: str, expected: str) -> None: + result = adapter_server.metadata_extension_action_from_evidence( + source="extension", + method_name="ПередЗаписью", + read_result={"text": f'&{annotation}("ПередЗаписью")\nПроцедура РасширениеПередЗаписью()\nКонецПроцедуры'}, + ) + + assert result["status"] == "ok" + assert result["operation_class"] == expected + + +def test_effective_extension_routine_scan_orders_and_proves_form_candidates(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "extension_map_by_guid", + lambda _base_id: { + "late": {"name": "Late", "guid": "late", "order": 2, "active": True}, + "early": {"name": "Early", "guid": "early", "order": 1, "active": True}, + }, + ) + monkeypatch.setattr( + adapter_server, + "search_modules", + lambda payload: { + "status": "ok", + "matches": [{"read_selector": {"module_ref": "ConfigCASSave:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa__f5f217c8-ac69-4e23-966b-d8ec0c665638.0"}}] + if payload["extension"] == "early" + else [], + }, + ) + monkeypatch.setattr( + adapter_server, + "repository_resolve_form_guid_sql", + lambda **_kwargs: ({"resolved_object": "Document.Тест.Form.Форма"}, None), + ) + monkeypatch.setattr( + adapter_server, + "read_module", + lambda _payload: {"status": "ok", "text": '&После("Метод")\nПроцедура РасшМетод()\nКонецПроцедуры'}, + ) + + layers = adapter_server.effective_extension_routine_scan("upo_test", "Метод", canonical_object="Document.Тест.Form.Форма") + + assert [layer["extension"]["name"] for layer in layers] == ["Early", "Late"] + assert layers[0]["applicability"] == "proven" + assert layers[0]["extension_actions"][0]["operation_class"] == "insert_after" + assert layers[1]["applicability"] == "not_found" + + def test_metadata_resolve_overrides_marks_unknown_extension_action(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( adapter_server, @@ -23402,7 +26574,10 @@ def test_metadata_resolve_overrides_marks_unknown_extension_action(monkeypatch: assert evidence["target"]["extension_action"] == action assert evidence["next_resolution"]["method"] == adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD assert evidence["next_resolution"]["params"]["query"] == "ПередЗаписью" - assert evidence["next_resolution"]["params"]["tables"] == ["ConfigCASSave"] + assert evidence["next_resolution"]["params"]["layer"] == "extension_saved_state" + assert evidence["next_resolution"]["params"]["ref"] == "Catalog.Номенклатура" + assert "tables" not in evidence["next_resolution"]["params"] + assert "owner_guid" not in evidence["next_resolution"]["params"] assert "intent" not in evidence @@ -23448,7 +26623,8 @@ def test_metadata_resolve_overrides_preserves_replace_with_control_action(monkey assert evidence["target"]["extension_action"] == action assert evidence["intent"]["operation"] == "replace_with_control" assert evidence["next_resolution"]["method"] == adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD - assert evidence["next_resolution"]["params"]["tables"] == ["ConfigCASSave"] + assert evidence["next_resolution"]["params"]["layer"] == "extension_saved_state" + assert evidence["next_resolution"]["params"]["ref"] == "Catalog.Номенклатура" def test_metadata_resolve_overrides_base_evidence_points_to_configsave(monkeypatch: pytest.MonkeyPatch) -> None: @@ -23481,8 +26657,10 @@ def test_metadata_resolve_overrides_base_evidence_points_to_configsave(monkeypat evidence = result["write_plan_evidence"] assert evidence["next_resolution"]["method"] == adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD - assert evidence["next_resolution"]["params"]["tables"] == ["ConfigSave"] - assert evidence["next_resolution"]["params"]["owner_guid"] == "owner-guid" + assert evidence["next_resolution"]["params"]["layer"] == "base_saved_state" + assert evidence["next_resolution"]["params"]["ref"] == "Catalog.Номенклатура" + assert "tables" not in evidence["next_resolution"]["params"] + assert "owner_guid" not in evidence["next_resolution"]["params"] assert evidence["target"]["object_guid"] == "owner-guid" diff --git a/tests/1c/test_repository_control.py b/tests/1c/test_repository_control.py index fb71d29..d8f949c 100644 --- a/tests/1c/test_repository_control.py +++ b/tests/1c/test_repository_control.py @@ -4,6 +4,8 @@ import json import sys from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector")) @@ -33,6 +35,21 @@ def configured_base(monkeypatch, tmp_path: Path, backend: str = "karman_bridge") monkeypatch.setenv("ONEC_REPOSITORY_STATE_FILE", str(tmp_path / "locks.json")) +def configured_layers(monkeypatch, tmp_path: Path) -> None: + config = { + "base": { + "server": "sql", "database": "db", "user": "reader", + "development_layers": { + "base": {"repository": {"mode": "manual"}, "support": {"mode": "rules", "rules": {"Документ.Заказ": "vendor_not_editable", "Документ.Заказ.Форма.ФормаДокумента": "vendor_editable_support_preserved"}}}, + "extension:11111111-2222-3333-4444-555555555555": {"repository": {"mode": "none"}, "support": {"mode": "none"}}, + "extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": {"repository": {"mode": "manual"}, "support": {"mode": "editable"}}, + }, + } + } + monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(config)) + monkeypatch.setenv("ONEC_REPOSITORY_STATE_FILE", str(tmp_path / "locks.json")) + + def test_repository_backend_and_endpoint_come_only_from_base_settings(monkeypatch, tmp_path: Path) -> None: configured_base(monkeypatch, tmp_path) config, error = repository_control.repository_config("base") @@ -56,7 +73,11 @@ def test_http_runner_profile_does_not_require_local_designer_or_credentials(monk def test_lock_plan_maps_child_metadata_to_development_owner() -> None: result = repository_control.lock_plan({"object": "РегистрСведений.Настройки.Реквизит.Код"}) assert result["status"] == "ready" + assert result["scope_status"] == "resolved" assert result["lock_objects"] == ["РегистрСведений.Настройки"] + assert result["native_lock_state"] == "unknown" + assert result["native_lock_verification"] == "not_available_via_infobase_sql" + assert result["capture_required"] is None def test_manual_lock_confirmation_is_scoped_and_not_reported_as_verified(monkeypatch, tmp_path: Path) -> None: @@ -67,10 +88,16 @@ def test_manual_lock_confirmation_is_scoped_and_not_reported_as_verified(monkeyp planned = repository_control.lock_plan({"base_id": "base", "object": "Справочник.Товары.МодульОбъекта"}) assert planned["workflow"] == "manual" + assert planned["next_method"] == "repository.lock.request" + assert planned["user_action"]["action"] == "confirm_lock_in_configurator" + assert "уже захвачены" in planned["user_action"]["message"] assert planned["user_action"]["objects"] == ["Справочник.Товары"] requested = repository_control.create_lock_request({"base_id": "base", "object": "Справочник.Товары"}) - confirmed = repository_control.confirm_manual_lock({"base_id": "base", "request_id": requested["request_id"], "user_confirmed_locked": True}) + confirmed = repository_control.confirm_manual_lock({"base_id": "base", "request_id": requested["request_id"], "user_confirmed_locked": True, "confirmed_repository_user": "repo-user"}) assert confirmed["status"] == "manual_confirmed" + assert confirmed["capture_state"] == "manually_confirmed" + assert confirmed["native_lock_state"] == "unknown" + assert confirmed["native_lock_verification"] == "not_available_via_infobase_sql" assert confirmed["automatically_verified"] is False verified = repository_control.verify({"lock_session_id": confirmed["lock_session_id"]}) assert verified["status"] == "manual_confirmation_unverified" @@ -88,17 +115,32 @@ def test_manual_lock_request_stays_pending_until_user_confirms_exact_saved_scope assert requested["status"] == "pending_user_lock" assert requested["objects"] == ["РегистрСведений.Настройки"] assert requested["automatically_locked"] is False + assert requested["next_call"] == { + "method": "repository.lock.confirm", + "params": {"base_id": "base", "request_id": requested["request_id"], "user_confirmed_locked": True}, + } pending = repository_control.lock_request_status({"request_id": requested["request_id"]}) assert pending["status"] == "pending_user_lock" + assert pending["next_call"] == requested["next_call"] + + incomplete = repository_control.confirm_manual_lock({"base_id": "base", "request_id": requested["request_id"]}) + assert incomplete["status"] == "confirmation_required" + assert incomplete["error"] == "user_confirmed_locked_required" + assert incomplete["required_fields"] == ["base_id", "request_id", "user_confirmed_locked"] + assert incomplete["next_call"] == requested["next_call"] + assert incomplete["sql_resolution"] == [] confirmed = repository_control.confirm_manual_lock({ "base_id": "base", "request_id": requested["request_id"], "objects": ["Справочник.Подмена"], "user_confirmed_locked": True, + "confirmed_repository_user": "repo-user", }) assert confirmed["objects"] == ["РегистрСведений.Настройки"] assert confirmed["automatically_verified"] is False + assert confirmed["write_context"]["lock_session_id"] == confirmed["lock_session_id"] + assert confirmed["write_context"]["repository_object"] == "РегистрСведений.Настройки" completed = repository_control.lock_request_status({"request_id": requested["request_id"]}) assert completed["status"] == "confirmed_by_user" @@ -148,6 +190,122 @@ def test_adapter_resolves_lock_request_object_against_live_sql(monkeypatch) -> N assert normalized["objects"] == ["РегистрСведений.Настройки"] +def test_form_lock_scope_is_resolved_before_plan_request_and_confirmation(monkeypatch, tmp_path: Path) -> None: + configured_base(monkeypatch, tmp_path) + form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638" + + def fake_get_object(kind, name, **kwargs): + assert kind == "Document" + assert name == "тл_Планировщик" + return {"status": "ok", "object": {"kind": "Document", "name": "тл_Планировщик", "guid": "owner-guid"}} + + monkeypatch.setattr(adapter_1c_server, "get_object", fake_get_object) + def fake_metadata_object_forms(payload): + assert "extension_guid" not in payload + return {"status": "ok", "forms": [{"guid": form_guid, "name": "ФормаДокументаНовая"}]} + + monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", fake_metadata_object_forms) + reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая" + planned = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "objects": [reference]}) + assert planned["status"] == "ready" + assert planned["lock_objects"] == [reference] + resolution = planned["sql_resolution"][0] + assert resolution["requested_object"] == reference + assert resolution["resolved_object"] == reference + assert resolution["form_guid"] == form_guid + assert resolution["owner"] == {"kind": "Document", "name": "тл_Планировщик", "guid": "owner-guid"} + assert resolution["table"] == "Config" + assert resolution["source"] == "live_sql_form_owner" + assert resolution["attempts"][-1] == {"stage": "form", "table": "Config", "status": "ok"} + request = adapter_1c_server.call_method("repository.lock.request", {"base_id": "base", "objects": [reference]}) + assert request["status"] == "pending_user_lock" + assert request["sql_resolution"] == planned["sql_resolution"] + confirmed = repository_control.confirm_manual_lock({ + "base_id": "base", "request_id": request["request_id"], "user_confirmed_locked": True, + "confirmed_repository_user": "repo-user", + }) + assert confirmed["status"] == "manual_confirmed" + assert repository_control.write_gate({"base_id": "base", "lock_session_id": confirmed["lock_session_id"], "repository_object": reference})["allowed"] is True + mismatch = repository_control.write_gate({"base_id": "base", "lock_session_id": confirmed["lock_session_id"], "repository_object": "Document.тл_Планировщик.Form.Другая"}) + assert mismatch["status"] == "blocked_repository_scope_mismatch" + assert mismatch["error"] == "lock_object_mismatch" + + +@pytest.mark.parametrize("kind", ["Catalog", "DataProcessor", "Report"]) +def test_owned_form_lock_resolver_supports_other_owner_kinds(monkeypatch, tmp_path: Path, kind: str) -> None: + configured_base(monkeypatch, tmp_path) + monkeypatch.setattr(adapter_1c_server, "get_object", lambda actual_kind, name, **kwargs: {"status": "ok", "object": {"kind": actual_kind, "name": name, "guid": "owner-guid"}}) + monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", lambda payload: {"status": "ok", "forms": [{"guid": "11111111-2222-3333-4444-555555555555", "name": "Основная"}]}) + result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": f"{kind}.Объект.Form.Основная"}) + assert result["status"] == "ready" + assert result["lock_objects"] == [f"{kind}.Объект.Form.Основная"] + + +def test_owned_extension_form_lock_discovers_its_sql_layer(monkeypatch, tmp_path: Path) -> None: + configured_layers(monkeypatch, tmp_path) + extension_guid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + reference = "Document.Заказ.Form.Основная" + calls: list[str | None] = [] + + def fake_resolve(**kwargs): + guid = kwargs.get("extension_guid") + calls.append(guid) + if guid != extension_guid: + return None, {"status": "not_found", "error": "repository_form_not_found_in_sql"} + return { + "requested_object": reference, + "resolved_object": reference, + "form_guid": "11111111-2222-3333-4444-555555555555", + "owner": {"kind": "Document", "name": "Заказ", "guid": "owner-guid"}, + "table": "ConfigCAS", + "source": "live_sql_form_owner", + "extension_guid": extension_guid, + }, None + + monkeypatch.setattr(adapter_1c_server, "repository_resolve_owned_form_sql", fake_resolve) + monkeypatch.setattr(adapter_1c_server, "list_extensions", lambda payload: { + "status": "ok", "extensions": [{"guid": extension_guid}], + }) + result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": reference}) + assert result["status"] == "ready" + assert result["layer_id"] == f"extension:{extension_guid}" + assert result["sql_resolution"][0]["source"] == "live_sql_extension_form_owner" + assert calls == [None, extension_guid] + + +def test_form_guid_lock_scope_uses_cached_owner_only_after_live_validation(monkeypatch, tmp_path: Path) -> None: + configured_base(monkeypatch, tmp_path) + guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638" + monkeypatch.setattr(adapter_1c_server, "metadata_form_owner_cache_lookup", lambda *args, **kwargs: {"owner": {"kind": "Document", "name": "тл_Планировщик"}, "form": {"name": "ФормаДокументаНовая"}}) + monkeypatch.setattr(adapter_1c_server, "get_object", lambda kind, name, **kwargs: {"status": "ok", "object": {"kind": kind, "name": name, "guid": "owner-guid"}}) + monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", lambda payload: {"status": "ok", "forms": [{"guid": guid, "name": "ФормаДокументаНовая"}]}) + result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": f"Form.{guid}"}) + assert result["status"] == "ready" + assert result["lock_objects"] == ["Document.тл_Планировщик.Form.ФормаДокументаНовая"] + + +def test_form_guid_lock_scope_falls_back_to_live_sql_owner_scan(monkeypatch, tmp_path: Path) -> None: + configured_base(monkeypatch, tmp_path) + guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638" + monkeypatch.setattr(adapter_1c_server, "metadata_form_owner_cache_lookup", lambda *args, **kwargs: None) + monkeypatch.setattr(adapter_1c_server, "list_objects", lambda kind, **kwargs: {"status": "ok", "objects": [{"kind": "Document", "name": "тл_Планировщик", "guid": "owner-guid"}] if kind == "Document" else []}) + monkeypatch.setattr(adapter_1c_server, "metadata_object_related", lambda payload: {"status": "ok", "related": [{"category": "Form", "identity": {"guid": guid, "name": "ФормаДокументаНовая"}}]}) + monkeypatch.setattr(adapter_1c_server, "get_object", lambda kind, name, **kwargs: {"status": "ok", "object": {"kind": kind, "name": name, "guid": "owner-guid"}}) + monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", lambda payload: {"status": "ok", "forms": [{"guid": guid, "name": "ФормаДокументаНовая"}]}) + result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": f"Form.{guid}"}) + assert result["status"] == "ready" + assert result["lock_objects"] == ["Document.тл_Планировщик.Form.ФормаДокументаНовая"] + + +def test_missing_owned_form_is_rejected_by_lock_plan(monkeypatch, tmp_path: Path) -> None: + configured_base(monkeypatch, tmp_path) + monkeypatch.setattr(adapter_1c_server, "get_object", lambda kind, name, **kwargs: {"status": "ok", "object": {"kind": kind, "name": name, "guid": "owner-guid"}}) + monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", lambda payload: {"status": "ok", "forms": []}) + result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": "Document.тл_Планировщик.Form.НетТакой"}) + assert result["status"] == "not_found" + assert result["error"] == "repository_form_not_found_in_sql" + + def test_structural_lock_plan_requires_confirmation() -> None: result = repository_control.lock_plan({"operation": "delete", "object": "Справочник.Склады"}) assert result["status"] == "needs_confirmation" @@ -160,11 +318,110 @@ def test_write_gate_requires_adapter_lock_for_configured_base(monkeypatch, tmp_p def test_adapter_dispatch_exposes_repository_status(monkeypatch, tmp_path: Path) -> None: configured_base(monkeypatch, tmp_path, backend="direct") + monkeypatch.setenv("ONEC_ADAPTER_ENABLE_EXTERNAL_1C", "true") result = adapter_1c_server.call_method("repository.status", {"base_id": "base"}) assert result["status"] == "configured" assert result["repository"]["backend"] == "direct" +def test_repository_confirmation_help_exposes_machine_readable_contract() -> None: + result = adapter_1c_server.call_method("help.methods", {"method": "repository.lock.confirm"}) + schema = result["methods"][0]["input_schema"] + assert schema["required"] == ["base_id", "request_id", "user_confirmed_locked"] + assert schema["example"]["user_confirmed_locked"] is True + assert "lock_session_id" in schema["notes"][-1] + + +def test_sql_only_repository_status_does_not_claim_repository_connection(monkeypatch, tmp_path: Path) -> None: + configured_base(monkeypatch, tmp_path) + monkeypatch.delenv("ONEC_ADAPTER_ENABLE_EXTERNAL_1C", raising=False) + result = repository_control.status({"base_id": "base", "probe": True}) + assert result["status"] == "manual_confirmation_required" + assert result["connected"] is False + assert result["available"] is None + assert result["sql_lock_evidence"]["native_lock_state"] == "unknown" + assert result["sql_lock_evidence"]["excluded_sources"]["_ConfigChngR"] == "exchange_plan_change_registration" + + +def test_repository_policy_is_selected_per_base_or_extension_guid(monkeypatch, tmp_path: Path) -> None: + configured_layers(monkeypatch, tmp_path) + base = repository_control.write_gate({"base_id": "base"}) + no_repository = repository_control.write_gate({"base_id": "base", "extension_guid": "11111111-2222-3333-4444-555555555555"}) + extension = repository_control.write_gate({"base_id": "base", "extension_guid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}) + assert base["status"] == "needs_repository_lock" + assert base["layer_id"] == "base" + assert no_repository == {"required": False, "allowed": True, "status": "repository_not_connected", "layer_id": "extension:11111111-2222-3333-4444-555555555555"} + assert extension["status"] == "needs_repository_lock" + assert extension["layer_id"] == "extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + missing = repository_control.write_gate({"base_id": "base", "extension_guid": "99999999-2222-3333-4444-555555555555"}) + assert missing["status"] == "blocked_repository_configuration" + assert missing["problem"]["status"] == "layer_not_configured" + + +def test_repository_connection_state_is_per_resolved_configuration_layer(monkeypatch, tmp_path: Path) -> None: + configured_layers(monkeypatch, tmp_path) + values = json.loads(__import__("os").environ["ONEC_SQL_BASES_JSON"]) + layers = values["base"]["development_layers"] + layers["base"]["repository"].update({"mode": "automatic", "connection_state": "unavailable", "repository_user": "repo-user", "runner": {"kind": "http", "url": "http://runner.invalid"}}) + layers["extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]["repository"]["connection_state"] = "unknown" + monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(values)) + + base = repository_control.lock_plan({"base_id": "base", "object": "ОбщийМодуль.Тест"}) + assert base["workflow"] == "manual" + assert base["status"] == "ready" + assert repository_control.status({"base_id": "base"})["status"] == "manual_confirmation_required" + extension = repository_control.write_gate({ + "base_id": "base", + "target": {"origin": {"extension": {"guid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}}}, + }) + assert extension["layer_id"] == "extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + assert extension["status"] == "blocked_repository_connection_unknown" + + +def test_manual_confirmation_uses_repository_user_saved_for_the_layer(monkeypatch, tmp_path: Path) -> None: + configured_base(monkeypatch, tmp_path) + values = json.loads(__import__("os").environ["ONEC_SQL_BASES_JSON"]) + values["base"]["repository"].update({"lock_mode": "manual", "connection_state": "unavailable"}) + monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(values)) + request = repository_control.create_lock_request({"base_id": "base", "object": "Справочник.Товары"}) + assert request["repository_user"] == "repo-user" + assert request["repository_user_source"] == "layer_connection_setting" + confirmed_without_repeating_user = repository_control.confirm_manual_lock({ + "base_id": "base", "request_id": request["request_id"], "user_confirmed_locked": True, + }) + assert confirmed_without_repeating_user["status"] == "manual_confirmed" + assert confirmed_without_repeating_user["repository_user"] == "repo-user" + + request = repository_control.create_lock_request({"base_id": "base", "object": "Справочник.Товары"}) + mismatch = repository_control.confirm_manual_lock({"base_id": "base", "request_id": request["request_id"], "user_confirmed_locked": True, "confirmed_repository_user": "other"}) + assert mismatch["error"] == "repository_user_mismatch" + confirmed = repository_control.confirm_manual_lock({"base_id": "base", "request_id": request["request_id"], "user_confirmed_locked": True, "confirmed_repository_user": "repo-user"}) + assert confirmed["repository_user"] == "repo-user" + + +def test_repository_confirmation_cannot_cross_extension_layers(monkeypatch, tmp_path: Path) -> None: + configured_layers(monkeypatch, tmp_path) + guid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + request = repository_control.create_lock_request({"base_id": "base", "extension_guid": guid, "object": "ОбщийМодуль.МодульРасширения"}) + confirmed = repository_control.confirm_manual_lock({"base_id": "base", "extension_guid": guid, "request_id": request["request_id"], "user_confirmed_locked": True, "confirmed_repository_user": "operator"}) + assert repository_control.write_gate({"base_id": "base", "extension_guid": guid, "lock_session_id": confirmed["lock_session_id"], "repository_object": "ОбщийМодуль.МодульРасширения"})["allowed"] is True + mismatch = repository_control.write_gate({"base_id": "base", "lock_session_id": confirmed["lock_session_id"], "repository_object": "ОбщийМодуль.МодульРасширения"}) + assert mismatch["status"] == "blocked_repository_lock_session" + + +def test_support_gate_uses_exact_target_scope_independently_from_repository_scope(monkeypatch, tmp_path: Path) -> None: + configured_layers(monkeypatch, tmp_path) + parent = repository_control.support_gate({"base_id": "base", "canonical_path": "Документ.Заказ"}) + form = repository_control.support_gate({"base_id": "base", "canonical_path": "Документ.Заказ.Форма.ФормаДокумента"}) + attribute = repository_control.support_gate({"base_id": "base", "canonical_path": "Документ.Заказ.Реквизит.Комментарий"}) + extension = repository_control.support_gate({"base_id": "base", "extension_guid": "11111111-2222-3333-4444-555555555555", "canonical_path": "ОбщийМодуль.Тест"}) + assert parent["allowed"] is False + assert form["allowed"] is True + assert form["matched_scope"] == "Документ.Заказ.Форма.ФормаДокумента" + assert attribute["allowed"] is False + assert extension["status"] == "not_on_support" + + def test_write_preflight_reports_repository_lock_requirement(monkeypatch, tmp_path: Path) -> None: configured_base(monkeypatch, tmp_path) monkeypatch.setattr( @@ -186,6 +443,208 @@ def test_write_preflight_reports_repository_lock_requirement(monkeypatch, tmp_pa assert result["repository"]["backend"] == "karman_bridge" +def test_write_preflight_uses_resolved_origin_layer_not_a_client_selector(monkeypatch, tmp_path: Path) -> None: + configured_layers(monkeypatch, tmp_path) + values = json.loads(__import__("os").environ["ONEC_SQL_BASES_JSON"]) + values["base"]["development_layers"]["extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]["repository"]["connection_state"] = "unknown" + monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(values)) + monkeypatch.setattr( + adapter_1c_server, + "metadata_write_plan", + lambda payload: { + "allowed": True, "status": "ready", "path_resolution": {}, + "target": {"origin": {"extension": {"guid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}}}, + "route": {}, "required_guards": [], "problems": [], + }, + ) + monkeypatch.setattr(adapter_1c_server, "metadata_write_preflight_saved_target", lambda payload, plan: {}) + monkeypatch.setattr(adapter_1c_server, "live_sql_support_gate", lambda payload: {"allowed": True, "status": "ok"}) + result = adapter_1c_server.metadata_write_preflight({"base_id": "base"}) + assert result["repository"]["layer_id"] == "extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + assert result["status"] == "blocked_repository_connection_unknown" + + +def test_form_module_write_gate_context_is_resolved_from_live_sql(monkeypatch) -> None: + form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638" + reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая" + monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({ + "resolved_object": reference, + "form_guid": form_guid, + "owner": {"kind": "Document", "name": "тл_Планировщик", "guid": "owner-guid"}, + }, None)) + context = adapter_1c_server.resolve_write_gate_context({ + "base_id": "neft", "module_id": f"Config:{form_guid}.0", + "repository_object": "Document.Другая.Форма.Другая", + }) + assert context["repository_object"] == reference + assert context["support_object_guid"] == form_guid + assert context["owner_resolution"]["layer_id"] == "base" + + +def test_confirm_write_context_can_be_forwarded_under_repository_lock() -> None: + context, error = adapter_1c_server.normalize_repository_write_context({ + "base_id": "neft", + "repository_lock": { + "lock_session_id": "rlock-test", + "repository_object": "Document.тл_Планировщик.Form.ФормаДокументаНовая", + "layer_id": "base", + }, + }, "metadata.write.preflight") + assert error is None + assert context["lock_session_id"] == "rlock-test" + assert context["repository_object"] == "Document.тл_Планировщик.Form.ФормаДокументаНовая" + assert context["layer_id"] == "base" + + _context, conflict = adapter_1c_server.normalize_repository_write_context({ + "lock_session_id": "rlock-one", + "write_context": {"lock_session_id": "rlock-two"}, + }, "code.write") + assert conflict["error"] == "repository_lock_context_conflict" + + +def test_preflight_uses_form_module_context_for_both_gates(monkeypatch) -> None: + form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638" + reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая" + monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({ + "resolved_object": reference, "form_guid": form_guid, + "owner": {"kind": "Document", "name": "тл_Планировщик"}, + }, None)) + monkeypatch.setattr(adapter_1c_server, "metadata_write_plan", lambda payload: { + "allowed": True, "status": "ready", "path_resolution": {}, "target": {}, + "route": {}, "required_guards": [], "problems": [], + }) + monkeypatch.setattr(adapter_1c_server, "metadata_write_preflight_saved_target", lambda payload, plan: {}) + captured: dict[str, dict] = {} + def fake_repository_gate(payload): + captured["repository"] = dict(payload) + return {"allowed": True, "status": "ready"} + + def fake_support_gate(payload): + captured["support"] = dict(payload) + return {"allowed": True, "status": "ready"} + + monkeypatch.setattr(repository_control, "write_gate", fake_repository_gate) + monkeypatch.setattr(adapter_1c_server, "live_sql_support_gate", fake_support_gate) + result = adapter_1c_server.metadata_write_preflight({ + "base_id": "neft", "module_id": f"Config:{form_guid}.0", + "repository_lock": {"lock_session_id": "rlock-test", "repository_object": reference, "layer_id": "base"}, + }) + assert result["owner_resolution"]["repository_object"] == reference + assert captured["repository"]["repository_object"] == reference + assert captured["repository"]["lock_session_id"] == "rlock-test" + assert captured["support"]["support_object_guid"] == form_guid + + +def test_form_module_preflight_returns_concrete_saved_state_prepare_plan(monkeypatch) -> None: + form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638" + monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({ + "resolved_object": "Document.тл_Планировщик.Form.ФормаДокументаНовая", + "form_guid": form_guid, "owner": {"kind": "Document", "name": "тл_Планировщик"}, + }, None)) + monkeypatch.setattr(adapter_1c_server, "metadata_write_plan", lambda payload: { + "allowed": False, "status": "needs_origin", "path_resolution": {}, "target": {}, + "route": {}, "required_guards": [], "problems": [{"code": "origin_lookup_required"}], + }) + monkeypatch.setattr(adapter_1c_server, "metadata_write_preflight_saved_target", lambda payload, plan: {}) + monkeypatch.setattr(adapter_1c_server, "metadata_write_resolve_module_target", lambda payload, target, mode: ({ + "error": "saved_state_prepare_required", + "prepared_module_ref": f"ConfigSave:{form_guid}.0", + "next_resolution": { + "method": "metadata.saved_state.prepare", + "params": { + "base_id": "neft", + "layer": "base_saved_state", + "ref": "Document.тл_Планировщик", + "mode": "plan", + }, + }, + "prepare_plan": {"status": "plan_ready"}, + }, None)) + monkeypatch.setattr(repository_control, "write_gate", lambda payload: {"allowed": True, "status": "ready"}) + monkeypatch.setattr(adapter_1c_server, "live_sql_support_gate", lambda payload: {"allowed": True, "status": "ready"}) + result = adapter_1c_server.metadata_write_preflight({"base_id": "neft", "module_id": f"Config:{form_guid}.0"}) + assert result["status"] == "needs_prepare" + assert result["saved_state"]["prepare_plan"]["status"] == "plan_ready" + assert result["route"]["auto_resolves_saved_state"] is True + assert result["route"]["next_resolution"]["method"] == "metadata.saved_state.prepare" + assert result["route"]["next_resolution"]["params"]["ref"] == "Document.тл_Планировщик" + assert "payload" not in result["route"]["next_resolution"] + + +def test_form_module_context_selects_resolved_extension_layer(monkeypatch) -> None: + form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638" + extension_guid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({ + "resolved_object": "Document.Заказ.Form.Основная", "form_guid": form_guid, + "owner": {"kind": "Document", "name": "Заказ"}, "extension_guid": extension_guid, + }, None)) + context = adapter_1c_server.resolve_write_gate_context({"base_id": "base", "module_id": f"Config:{form_guid}.0"}) + assert context["extension_guid"] == extension_guid + assert repository_control.development_layer_id(context) == f"extension:{extension_guid}" + assert context["target"]["origin"]["extension"]["guid"] == extension_guid + + +def test_code_write_carries_form_module_gate_context_into_metadata_write(monkeypatch) -> None: + form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638" + reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая" + monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({ + "resolved_object": reference, "form_guid": form_guid, + "owner": {"kind": "Document", "name": "тл_Планировщик"}, + }, None)) + seen: dict[str, object] = {} + monkeypatch.setattr(adapter_1c_server, "metadata_write", lambda payload: (seen.update(payload) or {"status": "blocked"})) + result = adapter_1c_server.code_write({"base_id": "neft", "module_id": f"Config:{form_guid}.0", "routine_text": "Процедура Тест()\nКонецПроцедуры"}) + assert result["owner_resolution"]["repository_object"] == reference + assert seen["repository_object"] == reference + assert seen["support_object_guid"] == form_guid + + +def test_saved_state_prepare_keeps_form_gate_context(monkeypatch) -> None: + form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638" + reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая" + captured: dict[str, object] = {} + monkeypatch.setattr(adapter_1c_server, "metadata_saved_state_prepare", lambda payload: ( + captured.update(payload) or {"status": "verified"} + )) + resolved, error = adapter_1c_server.metadata_write_resolve_module_target({ + "base_id": "neft", "module_id": f"Config:{form_guid}.0", + "allow_sql_saved_state_apply": True, + "lock_session_id": "rlock-test", + "repository_object": reference, + "support_object_guid": form_guid, + }, {}, "apply") + assert error is not None + assert captured["lock_session_id"] == "rlock-test" + assert captured["repository_object"] == reference + assert captured["support_object_guid"] == form_guid + assert resolved["module_ref"] == f"ConfigSave:{form_guid}.0" + + +def test_non_form_module_id_does_not_receive_form_write_gate_context(monkeypatch) -> None: + called = False + + def unexpected(**kwargs): + nonlocal called + called = True + return None, {"status": "not_found"} + + monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", unexpected) + context = adapter_1c_server.resolve_write_gate_context({"base_id": "neft", "module_id": "Config:not-a-guid.0"}) + assert "owner_resolution" not in context + assert called is False + + +def test_repository_layers_audit_reports_all_discovered_layers(monkeypatch, tmp_path: Path) -> None: + configured_layers(monkeypatch, tmp_path) + monkeypatch.setattr(adapter_1c_server, "list_extensions", lambda payload: {"status": "ok", "extensions": [{"name": "Тест", "guid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}]}) + monkeypatch.setattr(adapter_1c_server, "metadata_support_decode", lambda payload: {"status": "ok", "source": {"kind": "live_sql", "support_payload_present": False}, "suppliers": [{"general_mode": "editable"}], "diagnostics": {"source_discovery": "test"}, "object_support": {"status": "not_supported"}}) + result = adapter_1c_server.call_method("repository.layers.audit", {"base_id": "base"}) + assert result["status"] == "ok" + assert [layer["layer_id"] for layer in result["layers"]] == ["base", "extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"] + assert result["layers"][1]["safe_next_write_action"] == "request_and_confirm_repository_capture" + assert result["layers"][0]["support"]["general_modes"] == ["editable"] + + def test_direct_apply_method_is_repository_gated(monkeypatch, tmp_path: Path) -> None: configured_base(monkeypatch, tmp_path) result = adapter_1c_server.metadata_module_write_apply({"base_id": "base", "execution_mode": "apply"}) @@ -230,6 +689,5 @@ def test_sql_only_mode_never_calls_external_repository_runner(monkeypatch, tmp_p monkeypatch.setattr(repository_control, "_run_designer", forbidden) result = repository_control.lock({"base_id": "base", "object": "Справочник.Товары", "allow_repository_lock": True}) - assert result["status"] == "blocked" - assert result["execution"]["status"] == "external_1c_disabled" + assert result["status"] == "manual_action_required" assert called is False diff --git a/tests/1c/test_repository_sql_state.py b/tests/1c/test_repository_sql_state.py new file mode 100644 index 0000000..71cd1ba --- /dev/null +++ b/tests/1c/test_repository_sql_state.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "plugins" / "1c")) +sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector")) + +import adapter_1c_server # noqa: E402 + + +class FakeCursor: + def __init__(self) -> None: + self.rows = [] + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def execute(self, query, _params=None) -> None: + if "FROM dbo.[Params]" in query: + self.rows = [ + {"FileName": "a.ui", "PartNo": 0, "BinaryData": b"one"}, + {"FileName": "a.ui", "PartNo": 1, "BinaryData": b"two"}, + {"FileName": "b.si", "PartNo": 0, "BinaryData": b"three"}, + ] + else: + self.rows = [] + + def __iter__(self): + return iter(self.rows) + + def fetchall(self): + return list(self.rows) + + +class FakeConnection: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def cursor(self, as_dict=False): + assert as_dict is True + return FakeCursor() + + +def test_repository_sql_state_snapshot_hashes_parts_without_payload(monkeypatch) -> None: + monkeypatch.setattr( + adapter_1c_server, + "connect_live_sql", + lambda base_id, method, timeout_seconds=120: (FakeConnection(), {"database": "db"}, None), + ) + result = adapter_1c_server.repository_sql_state_snapshot( + {"base_id": "base", "tables": ["Params"], "include_extensions": False, "diagnostic": True} + ) + assert result["status"] == "ok" + assert result["counts"] == {"records": 2, "extensions": 0} + first = result["records"][0] + assert first["file_name"] == "a.ui" + assert first["part_count"] == 2 + assert first["bytes"] == 6 + assert "payload" not in first + assert result["diagnostics"]["payload_returned"] is False + token_diff = adapter_1c_server.repository_sql_state_diff( + { + "before_token": result["comparison_token"], + "after_token": result["comparison_token"], + "diagnostic": True, + } + ) + assert token_diff["status"] == "ok" + assert token_diff["changed"] is False + + +def test_repository_sql_state_diff_reports_changed_part() -> None: + before = { + "schema": "onec_repository_sql_state_snapshot.v1", + "base_id": "base", + "snapshot_id": "before", + "records": [ + { + "table": "Params", + "file_name": "a.ui", + "bytes": 3, + "sha1": "old", + "part_count": 1, + "parts": [{"part_no": 0, "bytes": 3, "sha1": "old"}], + } + ], + "extensions": [], + } + after = { + **before, + "snapshot_id": "after", + "records": [ + { + "table": "Params", + "file_name": "a.ui", + "bytes": 4, + "sha1": "new", + "part_count": 1, + "parts": [{"part_no": 0, "bytes": 4, "sha1": "new"}], + } + ], + } + result = adapter_1c_server.repository_sql_state_diff( + {"before": before, "after": after, "diagnostic": True} + ) + assert result["status"] == "ok" + assert result["changed"] is True + assert result["counts"] == {"added": 0, "removed": 0, "modified": 1} + assert result["modified"][0]["key"] == "storage:Params:a.ui" + assert result["modified"][0]["changed_parts"] == ["0"] diff --git a/tests/1c/test_scheduled_job_parser.py b/tests/1c/test_scheduled_job_parser.py new file mode 100644 index 0000000..dc856c0 --- /dev/null +++ b/tests/1c/test_scheduled_job_parser.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "plugins" / "1c")) + +from parser.scheduled_job import decode_schedule, schedule_write_edits # noqa: E402 + + +def atom(value: Any) -> dict[str, Any]: + return {"type": "atom", "value": str(value)} + + +def schedule_tree() -> dict[str, Any]: + return { + "type": "list", + "items": [ + atom("00010101000000"), + atom("00010101000000"), + atom("00010101090000"), + atom("00010101180000"), + atom("00010101000000"), + atom(0), + atom(0), + atom(0), + atom(2), + atom(1), + atom(5), + atom(0), + atom(0), + atom(1), + atom(1), + atom(1), + atom(1), + ], + } + + +def test_decode_schedule_is_adapter_independent() -> None: + result = decode_schedule(schedule_tree()) + + assert result["status"] == "ok" + assert result["begin_time"] == "09:00:00" + assert result["end_time"] == "18:00:00" + assert result["week_days"] == [1, 5] + assert result["months"] == [1] + + +def test_schedule_write_edits_rebuilds_resized_collections() -> None: + result = schedule_write_edits( + schedule_tree(), + {"week_days": [1, 2, 3, 4, 5], "months": [1, 6, 12]}, + ) + + assert result["status"] == "ok" + assert result["counts"]["resized_collections"] == 2 + rebuilt = decode_schedule(result["edits"][0]["replace_root"]) + assert rebuilt["week_days"] == [1, 2, 3, 4, 5] + assert rebuilt["months"] == [1, 6, 12] + + +def test_schedule_write_edits_rejects_unknown_named_field() -> None: + result = schedule_write_edits(schedule_tree(), {"sql_path": "1.2.3"}) + + assert result["status"] == "invalid_argument" + assert result["error"] == "unsupported_schedule_fields" + assert result["diagnostics"]["fields"] == ["sql_path"] diff --git a/tests/1c/test_support_rules.py b/tests/1c/test_support_rules.py new file mode 100644 index 0000000..9639f28 --- /dev/null +++ b/tests/1c/test_support_rules.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "plugins" / "1c")) +sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector")) + +import adapter_1c_server # noqa: E402 +from parser.support_rules import parse_parent_configurations_bytes + + +GUID = "8cec71dd-5046-4b16-ab13-cca39c826ca2" +CONFIG_GUID = "8cbe2d2d-295e-4b77-813b-63588e84f87a" +OTHER_GUID = "1fbe6b79-1ea3-43f9-bf72-f6cd4da9743b" + + +def payload(general_mode: int = 0, rule: int = 1) -> bytes: + text = ( + f'{{6,0,1,{CONFIG_GUID},{general_mode},{OTHER_GUID},"1.2.3",' + f'"Поставщик","Конфигурация",1,{rule},0,{GUID},{GUID},0,0}}' + ) + return text.encode("utf-8-sig") + + +def test_decodes_editable_support_rule() -> None: + result = parse_parent_configurations_bytes(payload()) + supplier = result["suppliers"][0] + assert result["supplier_count"] == 1 + assert supplier.general_mode == "editable" + assert supplier.name == "Конфигурация" + assert supplier.rules[0].object_guid == GUID + assert supplier.rules[0].rule == "editable_support_preserved" + + +def test_locked_general_mode_overrides_object_rule() -> None: + result = parse_parent_configurations_bytes(payload(general_mode=1, rule=2)) + supplier = result["suppliers"][0] + assert supplier.general_mode == "locked" + assert supplier.rules[0].rule_code == 0 + assert supplier.rules[0].rule == "not_editable" + + +@pytest.mark.parametrize( + ("data", "message"), + [ + (b'{5,0,0}', "expected format marker 6"), + (b'{6,0,1,not-a-guid,0,x,v,p,n,0,0,0}', "configuration GUID is invalid"), + (payload(rule=9), "unsupported object rule code 9"), + ], +) +def test_rejects_invalid_support_payload(data: bytes, message: str) -> None: + with pytest.raises(ValueError, match=message): + parse_parent_configurations_bytes(data) + + +def test_sql_method_decodes_only_explicit_source_and_filters_object(monkeypatch) -> None: + monkeypatch.setattr( + adapter_1c_server, + "read_storage_file_bytes", + lambda base_id, table, file_name, timeout_seconds=30: (payload(), {"database": "db"}, None), + ) + result = adapter_1c_server.metadata_support_decode( + {"base_id": "base", "table": "Params", "file_name": "support.si", "object_guid": GUID} + ) + assert result["status"] == "ok" + assert result["source"] == {"kind": "live_sql", "database": "db", "table": "Params", "file_name": "support.si"} + assert result["matches"][0]["rule"] == "editable_support_preserved" + assert result["object_support"]["status"] == "editable_support_preserved" + assert result["object_support"]["edit_allowed_by_support"] is True + + +def test_sql_method_resolves_public_1c_name_before_filtering_support(monkeypatch) -> None: + captured: dict[str, str] = {} + + def get_object(kind, name, **kwargs): + captured["kind"] = kind + captured["name"] = name + return { + "status": "ok", + "object": { + "kind": "CommonModule", + "name": "ОбменДанными", + "guid": GUID, + "ref": "CommonModule.ОбменДанными", + }, + } + + monkeypatch.setattr(adapter_1c_server, "get_object", get_object) + monkeypatch.setattr( + adapter_1c_server, + "read_storage_file_bytes", + lambda base_id, table, file_name, timeout_seconds=30: (payload(), {"database": "db"}, None), + ) + + result = adapter_1c_server.call_method( + "metadata.support.decode", + { + "base_id": "base", + "ref": "ОбщиеМодули.ОбменДанными", + "table": "Params", + "file_name": "support.si", + }, + ) + + assert result["status"] == "ok" + assert captured == {"kind": "CommonModule", "name": "ОбменДанными"} + assert result["query"]["object_guid"] == GUID + assert result["query"]["object"]["ref"] == "CommonModule.ОбменДанными" + assert result["object_support"]["status"] == "editable_support_preserved" + + +def test_support_decode_help_publishes_optional_name_selector() -> None: + methods = { + row["name"]: row + for row in adapter_1c_server.call_method("help.methods", {})["methods"] + } + method = methods["metadata.support.decode"] + + assert method["selector_capabilities"]["selector_purpose"] == "optional_object_support_filter" + assert method["selector_capabilities"]["accepts_ordinal"] is False + assert method["input_schema"]["properties"]["object_name"]["type"] == "string" + + +def test_sql_method_treats_absent_guid_as_not_supported_after_complete_decode(monkeypatch) -> None: + monkeypatch.setattr( + adapter_1c_server, + "read_storage_file_bytes", + lambda base_id, table, file_name, timeout_seconds=30: (payload(), {"database": "db"}, None), + ) + result = adapter_1c_server.metadata_support_decode( + {"base_id": "base", "table": "Config", "file_name": "support.bin", "object_guid": OTHER_GUID} + ) + assert result["status"] == "ok" + assert result["matches"] == [] + assert result["object_support"] == { + "object_guid": OTHER_GUID, + "status": "not_supported", + "edit_allowed_by_support": True, + "evidence": "absent_from_complete_parent_configurations", + } + assert result["diagnostics"]["repository_lock_evaluated"] is False + + +def test_live_sql_support_gate_resolves_public_object_name(monkeypatch) -> None: + monkeypatch.setattr( + adapter_1c_server.repository_control, + "support_gate", + lambda request: {"required": True, "allowed": False, "status": "blocked_support_unknown"}, + ) + monkeypatch.setattr( + adapter_1c_server, + "get_object", + lambda kind, name, **kwargs: {"status": "ok", "object": {"guid": GUID}}, + ) + monkeypatch.setattr( + adapter_1c_server, + "metadata_support_decode", + lambda request: { + "status": "ok", + "object_support": { + "status": "not_supported", + "edit_allowed_by_support": True, + "evidence": "absent_from_complete_parent_configurations", + }, + "diagnostics": {"source_discovery": "base_root_descriptor"}, + }, + ) + gate = adapter_1c_server.live_sql_support_gate( + {"base_id": "base", "layer_id": "base", "ref": "ОбщийМодуль.Модуль"} + ) + assert gate["allowed"] is True + assert gate["required"] is False + assert gate["mode"] == "live_sql" + assert gate["object_guid"] == GUID + + +def test_sql_method_discovers_base_source_and_rejects_invalid_payload(monkeypatch) -> None: + monkeypatch.setattr( + adapter_1c_server, + "discover_base_support_source", + lambda base_id, timeout_seconds=30: ( + { + "kind": "live_sql", + "database": "db", + "table": "Config", + "file_name": f"{CONFIG_GUID}.4", + "root_file": OTHER_GUID, + "configuration_id": CONFIG_GUID, + "discovery": "base_root_descriptor", + }, + None, + ), + ) + monkeypatch.setattr( + adapter_1c_server, + "read_storage_file_bytes", + lambda base_id, table, file_name, timeout_seconds=30: (payload(), {"database": "db"}, None), + ) + discovered = adapter_1c_server.metadata_support_decode({"base_id": "base", "object_guid": GUID}) + assert discovered["status"] == "ok" + assert discovered["source"]["file_name"] == f"{CONFIG_GUID}.4" + assert discovered["diagnostics"]["source_discovery"] == "base_root_descriptor" + + monkeypatch.setattr( + adapter_1c_server, + "discover_extension_support_source", + lambda base_id, extension_guid, timeout_seconds=30: ( + { + "kind": "live_sql", + "table": "ConfigCAS", + "configuration_id": OTHER_GUID, + "support_payload_present": False, + "discovery": "extension_cas_manifest", + }, + None, + ), + ) + extension = adapter_1c_server.metadata_support_decode( + {"base_id": "base", "layer_id": f"extension:{CONFIG_GUID}", "object_guid": GUID} + ) + assert extension["status"] == "ok" + assert extension["object_support"]["status"] == "not_supported" + assert extension["object_support"]["edit_allowed_by_support"] is True + assert extension["diagnostics"]["repository_lock_evaluated"] is False + + monkeypatch.setattr( + adapter_1c_server, + "read_storage_file_bytes", + lambda base_id, table, file_name, timeout_seconds=30: (b"{0}", {"database": "db"}, None), + ) + invalid = adapter_1c_server.metadata_support_decode( + {"base_id": "base", "table": "Params", "file_name": "not-support.si"} + ) + assert invalid["status"] == "invalid_format" + assert invalid["diagnostics"]["permission_inference"] == "forbidden" diff --git a/tests/1c/test_write_plan_safety_smoke.py b/tests/1c/test_write_plan_safety_smoke.py new file mode 100644 index 0000000..34676b8 --- /dev/null +++ b/tests/1c/test_write_plan_safety_smoke.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +import scripts.smoke_1c_write_plan_safety as smoke # noqa: E402 + + +def test_repository_gate_falls_back_to_non_mutating_path_route_probe(monkeypatch) -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + def fake_rpc_call( + _endpoint_url: str, + method: str, + payload: dict[str, Any], + _timeout: float, + *, + transport: str, + session_id: str | None, + ) -> dict[str, Any]: + calls.append((method, payload)) + assert transport == "rest" + assert session_id is None + if method == "metadata.write": + if payload.get("mode") == "apply": + return { + "schema": "onec_repository_write_gate.v1", + "status": "blocked", + "error": "needs_repository_lock", + } + return { + "schema": "onec_metadata_write.v1", + "status": "blocked", + "error": "write_plan_required", + "routed_method": "metadata.write.plan", + "target_kind": "form", + } + intent = payload.get("intent") or {} + if intent.get("current_text") is not None: + return { + "status": "blocked", + "allowed": False, + "problems": [{"code": "control_fragment_drift"}], + } + if intent.get("control_fragment"): + return { + "status": "planned", + "allowed": True, + "route": {"apply_method": "metadata.module.write_apply"}, + "target": {"target_kind": "module"}, + } + return { + "status": "blocked", + "allowed": False, + "problems": [{"code": "missing_control_fragment"}], + } + + monkeypatch.setattr(smoke, "rpc_call", fake_rpc_call) + + result = smoke.run_smoke("http://adapter", "upo_test", 30, transport="rest") + + assert result["status"] == "ok" + assert result["failures"] == [] + assert result["checks"]["blocked_effective_form_path"]["apply_gate"] == { + "status": "blocked", + "error": "needs_repository_lock", + } + metadata_write_modes = [payload["mode"] for method, payload in calls if method == "metadata.write"] + assert metadata_write_modes == ["apply", "plan"] diff --git a/tests/1c/test_write_preflight_smoke.py b/tests/1c/test_write_preflight_smoke.py new file mode 100644 index 0000000..3408b5c --- /dev/null +++ b/tests/1c/test_write_preflight_smoke.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +import scripts.smoke_1c_write_preflight as smoke # noqa: E402 + + +def test_repository_lock_is_a_valid_preflight_classification() -> None: + assert smoke.classified_preflight_status("needs_repository_lock") is True + assert smoke.classified_preflight_status("unexpected") is False + + +def test_saved_module_target_prefers_bounded_stream(monkeypatch) -> None: + whole_form = { + "kind": "module", + "module_ref": "ConfigCASSave:extension__form.0", + "file_name": "extension__form.0", + "module_path": "2", + } + bounded_stream = { + "kind": "module", + "module_ref": "ConfigSave:module.3#stream:4", + "file_name": "module.3", + "stream_index": 4, + } + + def fake_rpc_call( + _endpoint_url: str, + method: str, + _payload: dict[str, Any], + _timeout: float, + *, + transport: str, + session_id: str | None, + ) -> dict[str, Any]: + assert method == "metadata.saved_state.modules.search" + assert transport == "rest" + assert session_id is None + return { + "status": "ok", + "modules": [ + {"streams": [{"write_plan_target": whole_form}]}, + {"streams": [{"write_plan_target": bounded_stream}]}, + ], + } + + monkeypatch.setattr(smoke, "rpc_call", fake_rpc_call) + + result = smoke.first_saved_module_target( + "http://adapter", + "upo_test", + 30, + transport="rest", + session_id=None, + ) + + assert result == bounded_stream