From d7099bf80d19a100ce59d08c9d6d8cc52a99fb9b Mon Sep 17 00:00:00 2001 From: Mikhail Date: Fri, 14 Aug 2026 09:40:51 +0300 Subject: [PATCH] Initial project import --- .../onec-sql-extension-adapter/SKILL.md | 58 + .../agents/openai.yaml | 4 + .../references/protocol-and-test-matrix.md | 36 + AGENTS.md | 19 + core/deploy/README.md | 7 +- core/deploy/docker-gpu/README.md | 21 + .../deploy/docker-gpu/adapter-1c/compose.yaml | 33 + .../deploy/docker-gpu/embeddings/.env.example | 18 + .../deploy/docker-gpu/embeddings/compose.yaml | 51 + .../docker/1c-agent/1c-agent.env.example | 6 +- core/deploy/docker/1c-agent/compose.yaml | 4 +- .../deploy/docker/adapter-1c-mcp/.env.example | 6 +- .../deploy/docker/adapter-1c-mcp/compose.yaml | 25 +- core/deploy/docker/adapter-1c/.env.example | 20 + core/deploy/docker/adapter-1c/compose.yaml | 63 + .../docker/adapter-observer/.env.example | 12 + .../docker/adapter-observer/compose.yaml | 42 + docs/1c-adapter-api-contract.md | 372 +- docs/1c-sql-format-spec.md | 5 + docs/1c-sql-protocol/README.md | 46 + docs/1c-sql-protocol/codecs/configinfo.md | 35 + .../codecs/payload-envelope.md | 27 + .../experiments/upo-test-test2.md | 72 + .../extensions/saved-state-overlay.md | 128 + .../implementation/adapter-components.md | 24 + .../layers/configuration-storage.md | 39 + .../objects/common-form-module.md | 28 + .../objects/component-graph.md | 55 + .../objects/report-object-module.md | 115 + .../samples/manual-change-2.json | 12 + .../samples/manual-change-3.json | 12 + .../operations/configurator-cache.md | 35 + .../research/known-dead-ends.md | 27 + docs/1c-sql-protocol/rules/agent-contract.md | 51 + .../rules/evidence-and-safety.md | 55 + .../templates/experiment-record.md | 41 + docs/1c-write-handler-architecture.md | 121 + docs/adapter-observer-handoff.md | 224 + .../1c-code-vector-search-evaluation.md | 54 + docs/runbooks/1c-agent-coding-contract.md | 122 +- docs/runbooks/1c-code-embeddings.md | 132 + .../1c-form-command-binding-learning.md | 10 +- docs/runbooks/1c-form-discovery.md | 16 +- docs/runbooks/1c-metadata-structure.md | 124 + docs/runbooks/1c-moxel-discovery.md | 2 +- docs/runbooks/1c-operational-coding.md | 63 +- docs/runbooks/adapter-1c-mcp.md | 41 +- docs/runbooks/adapter-observer.md | 130 + .../additional-attributes-runtime-bridge.md | 50 + plugins/1c/agent/Dockerfile | 1 + plugins/1c/agent/agent_server.py | 102 +- plugins/1c/bridge/README.md | 32 + .../additional_attributes_http_handler.bsl | 60 + .../bridge/additional_attributes_readonly.bsl | 85 + plugins/1c/connector/.env.example | 11 + plugins/1c/connector/Dockerfile | 2 + plugins/1c/connector/README.md | 172 +- plugins/1c/connector/adapter_1c_server.py | 17030 +++++++++++++++- plugins/1c/connector/analyze_audit.py | 53 + plugins/1c/connector/contracts/openapi.yaml | 379 +- .../sql-extension-activity-investigations.md | 164 + .../designer-sql-decoding-policy.yaml | 26 +- plugins/1c/connector/repository_control.py | 569 +- plugins/1c/connector/write/__init__.py | 1 + plugins/1c/connector/write/context.py | 20 + plugins/1c/connector/write/contracts.py | 14 + .../1c/connector/write/handlers/__init__.py | 5 + plugins/1c/connector/write/handlers/form.py | 5 + plugins/1c/connector/write/handlers/module.py | 3 + .../connector/write/handlers/object_member.py | 3 + .../write/handlers/object_property.py | 3 + .../connector/write/handlers/scheduled_job.py | 11 + plugins/1c/connector/write/registry.py | 45 + plugins/1c/mcp/Dockerfile | 1 + plugins/1c/mcp/adapter_1c_mcp.py | 396 +- plugins/1c/mcp/analyze_audit.py | 34 + plugins/1c/observer/Dockerfile | 13 + plugins/1c/observer/README.md | 14 + plugins/1c/observer/observer_server.py | 345 + plugins/1c/observer/web/assets/app.js | 54 + plugins/1c/observer/web/assets/style.css | 13 + plugins/1c/observer/web/index.html | 10 + plugins/1c/parser/cas_payload.py | 226 +- plugins/1c/parser/form_payload.py | 44 +- plugins/1c/parser/scd_payload.py | 463 + plugins/1c/prompts/system.md | 9 +- scripts/analyze_1c_adapter_audit.py | 67 + scripts/analyze_1c_moxel_merge_row_bands.py | 2 +- .../analyze_1c_moxel_merge_slot_candidates.py | 2 +- scripts/audit_1c_adapter_coverage.py | 2 +- scripts/capture_1c_template_probe.py | 2 +- .../check_1c_adapter_verification_stack.py | 2 +- scripts/check_1c_mcp_adapter_contract.py | 9 +- scripts/check_1c_metadata_kind_fixtures.py | 4 +- .../check_1c_saved_state_strict_readiness.py | 2 +- scripts/check_1c_verify_reports.py | 5 +- scripts/deploy_1c_adapter_stack.ps1 | 66 +- scripts/deploy_embeddings.ps1 | 89 + scripts/embed_1c_code_vectors.py | 231 + scripts/embed_1c_semantic_cache.py | 2 +- scripts/execute_1c_saved_state_copy_sql.ps1 | 2 +- scripts/export_1c_access_role_audit.py | 2 +- .../inventory_1c_configuration_templates.py | 2 +- scripts/inventory_1c_template_payloads.py | 2 +- scripts/plan_1c_saved_state_copy.py | 2 +- scripts/profile_1c_extension_templates.py | 2 +- scripts/profile_1c_forms.py | 2 +- scripts/profile_1c_tabular_templates.py | 2 +- scripts/rag_embedding_providers.py | 14 +- scripts/report_1c_agent_working_view.py | 2 +- .../run_1c_form_command_binding_learning.py | 2 +- scripts/run_1c_repository_runner.ps1 | 68 +- scripts/run_1c_repository_runner.py | 57 +- scripts/search_1c_code_vectors.py | 187 + scripts/smoke_1c_access_object.py | 2 +- scripts/smoke_1c_access_snapshot.py | 2 +- scripts/smoke_1c_code_write_saved_state.py | 2 +- .../smoke_1c_extension_saved_state_prepare.py | 112 + scripts/smoke_1c_mcp_selector_chain.py | 54 +- scripts/smoke_1c_saved_state_changes.py | 2 +- scripts/smoke_1c_saved_state_diff.py | 2 +- scripts/smoke_1c_saved_state_module_write.py | 51 +- scripts/smoke_1c_saved_state_write_routes.py | 2 +- scripts/smoke_1c_write_matrix.py | 2 +- scripts/smoke_1c_write_plan_safety.py | 2 +- scripts/smoke_1c_write_preflight.py | 2 +- scripts/smoke_1c_write_rollback_safety.py | 2 +- scripts/track_1c_template_history.py | 2 +- scripts/verify_1c_adapter_deployment.ps1 | 24 +- scripts/verify_1c_saved_state_copy.py | 2 +- scripts/watch_1c_moxel_property_experiment.py | 2 +- tests/1c/test_adapter_observer.py | 36 + tests/1c/test_additional_attributes.py | 138 + tests/1c/test_code_vector_embedding_worker.py | 105 + tests/1c/test_code_vector_search_cli.py | 102 + .../1c/test_configurator_refresh_guidance.py | 40 + tests/1c/test_extension_common_form_route.py | 62 + .../1c/test_extension_configinfo_map_codec.py | 193 + tests/1c/test_mcp_selector_policy.py | 102 + tests/1c/test_metadata_object_components.py | 58 + tests/1c/test_onec_agent_api.py | 83 +- tests/1c/test_payload_codec.py | 6114 +++++- tests/1c/test_repository_control.py | 164 + tests/1c/test_sql_base_access_policy.py | 4 +- tests/1c/test_structural_stream_parser.py | 112 + tests/1c/test_write_handler_registry.py | 42 + 146 files changed, 30509 insertions(+), 1055 deletions(-) create mode 100644 .codex/skills/onec-sql-extension-adapter/SKILL.md create mode 100644 .codex/skills/onec-sql-extension-adapter/agents/openai.yaml create mode 100644 .codex/skills/onec-sql-extension-adapter/references/protocol-and-test-matrix.md create mode 100644 core/deploy/docker-gpu/embeddings/.env.example create mode 100644 core/deploy/docker-gpu/embeddings/compose.yaml create mode 100644 core/deploy/docker/adapter-1c/.env.example create mode 100644 core/deploy/docker/adapter-1c/compose.yaml create mode 100644 core/deploy/docker/adapter-observer/.env.example create mode 100644 core/deploy/docker/adapter-observer/compose.yaml create mode 100644 docs/1c-sql-protocol/README.md create mode 100644 docs/1c-sql-protocol/codecs/configinfo.md create mode 100644 docs/1c-sql-protocol/codecs/payload-envelope.md create mode 100644 docs/1c-sql-protocol/experiments/upo-test-test2.md create mode 100644 docs/1c-sql-protocol/extensions/saved-state-overlay.md create mode 100644 docs/1c-sql-protocol/implementation/adapter-components.md create mode 100644 docs/1c-sql-protocol/layers/configuration-storage.md create mode 100644 docs/1c-sql-protocol/objects/common-form-module.md create mode 100644 docs/1c-sql-protocol/objects/component-graph.md create mode 100644 docs/1c-sql-protocol/objects/report-object-module.md create mode 100644 docs/1c-sql-protocol/objects/report-object-module/samples/manual-change-2.json create mode 100644 docs/1c-sql-protocol/objects/report-object-module/samples/manual-change-3.json create mode 100644 docs/1c-sql-protocol/operations/configurator-cache.md create mode 100644 docs/1c-sql-protocol/research/known-dead-ends.md create mode 100644 docs/1c-sql-protocol/rules/agent-contract.md create mode 100644 docs/1c-sql-protocol/rules/evidence-and-safety.md create mode 100644 docs/1c-sql-protocol/templates/experiment-record.md create mode 100644 docs/1c-write-handler-architecture.md create mode 100644 docs/adapter-observer-handoff.md create mode 100644 docs/research/1c-code-vector-search-evaluation.md create mode 100644 docs/runbooks/1c-code-embeddings.md create mode 100644 docs/runbooks/1c-metadata-structure.md create mode 100644 docs/runbooks/adapter-observer.md create mode 100644 docs/runbooks/additional-attributes-runtime-bridge.md create mode 100644 plugins/1c/bridge/README.md create mode 100644 plugins/1c/bridge/additional_attributes_http_handler.bsl create mode 100644 plugins/1c/bridge/additional_attributes_readonly.bsl create mode 100644 plugins/1c/connector/analyze_audit.py create mode 100644 plugins/1c/connector/docs/sql-extension-activity-investigations.md create mode 100644 plugins/1c/connector/write/__init__.py create mode 100644 plugins/1c/connector/write/context.py create mode 100644 plugins/1c/connector/write/contracts.py create mode 100644 plugins/1c/connector/write/handlers/__init__.py create mode 100644 plugins/1c/connector/write/handlers/form.py create mode 100644 plugins/1c/connector/write/handlers/module.py create mode 100644 plugins/1c/connector/write/handlers/object_member.py create mode 100644 plugins/1c/connector/write/handlers/object_property.py create mode 100644 plugins/1c/connector/write/handlers/scheduled_job.py create mode 100644 plugins/1c/connector/write/registry.py create mode 100644 plugins/1c/mcp/analyze_audit.py create mode 100644 plugins/1c/observer/Dockerfile create mode 100644 plugins/1c/observer/README.md create mode 100644 plugins/1c/observer/observer_server.py create mode 100644 plugins/1c/observer/web/assets/app.js create mode 100644 plugins/1c/observer/web/assets/style.css create mode 100644 plugins/1c/observer/web/index.html create mode 100644 plugins/1c/parser/scd_payload.py create mode 100644 scripts/analyze_1c_adapter_audit.py create mode 100644 scripts/deploy_embeddings.ps1 create mode 100644 scripts/embed_1c_code_vectors.py create mode 100644 scripts/search_1c_code_vectors.py create mode 100644 scripts/smoke_1c_extension_saved_state_prepare.py create mode 100644 tests/1c/test_adapter_observer.py create mode 100644 tests/1c/test_additional_attributes.py create mode 100644 tests/1c/test_code_vector_embedding_worker.py create mode 100644 tests/1c/test_code_vector_search_cli.py create mode 100644 tests/1c/test_configurator_refresh_guidance.py create mode 100644 tests/1c/test_extension_common_form_route.py create mode 100644 tests/1c/test_extension_configinfo_map_codec.py create mode 100644 tests/1c/test_mcp_selector_policy.py create mode 100644 tests/1c/test_metadata_object_components.py create mode 100644 tests/1c/test_structural_stream_parser.py create mode 100644 tests/1c/test_write_handler_registry.py diff --git a/.codex/skills/onec-sql-extension-adapter/SKILL.md b/.codex/skills/onec-sql-extension-adapter/SKILL.md new file mode 100644 index 0000000..808b442 --- /dev/null +++ b/.codex/skills/onec-sql-extension-adapter/SKILL.md @@ -0,0 +1,58 @@ +--- +name: onec-sql-extension-adapter +description: Develop, diagnose, or test this repository's SQL-only 1C adapter, especially extension saved-state preparation and public code writes. Use only inside the adapter repository; do not use it for ordinary client projects that consume the adapter API. +--- + +# 1C SQL Extension Adapter Development + +Use this project-local skill while changing or diagnosing the adapter itself. It protects the boundary between a simple client API and the adapter's private SQL implementation. + +## Two contracts — never mix them + +### Client / consuming-project contract + +Callers use only public high-level operations and business selectors: `base_id`, extension name, object reference/name, requested change, and normal confirmation scope. They may read or write through the adapter, but they must not know or supply SQL table names, module references, stream indexes, file keys, hashes, containers, or serialization details. + +An absent row in `ConfigCASSave` is not a caller problem. A caller must never be told to create it, save an extension again, initialize Configurator, or discover a technical route merely because the adapter failed to prepare saved state. + +### Adapter-development contract + +The adapter owns all SQL state preparation. For an extension write it must, from public selectors, resolve the exact extension layer, determine the writable module, create missing `ConfigCASSave` rows through the already proven copy path, write, reread, and report the result. Its implementation may inspect SQL evidence, but must never invent a container, mapping, payload, or reverse codec. + +## Mandatory investigation workflow + +1. Reproduce using the same public request that a client uses. Preserve its request ID and timings. +2. Confirm the target through public read/search APIs: object owner, extension origin, module ordinal, and the exact old text. Do not infer from names alone. +3. Inspect active and saved state independently. Classify each component as present-and-matching, absent, mismatched, ambiguous, or unreadable. +4. Select the storage-key layout from live evidence in that base and extension family. A successful base-configuration `ConfigSave` path does not prove an extension `ConfigCASSave` path; equally, one canonical descriptor layout does not license renaming hash-keyed rows in another layout. A hash-keyed active extension root may itself decode to the proven logical file map; only then may it be mapped to canonical saved-state names. +5. If the project has already proven the copy codec for the selected layout, use it for an absent component. Do not discard that proof merely because a new generic resolver expects a different descriptor or `__configinfo`. If a genuinely new layout is encountered, return a protocol-specific blocked result and develop it; never turn that gap into a Configurator instruction for the client. +6. Test the exact public route with `plan`, then a controlled `apply_and_rollback` on `upo_test`. Verify readback and cleanup. Only then enable `apply_and_verify` for the route. + +Read [the protocol and test reference](references/protocol-and-test-matrix.md) before altering saved-state preparation or interpreting its errors. + +## Error taxonomy + +| Result | Meaning | Required next step | +|---|---|---| +| `public_write_route_unresolved` | Public-to-internal resolver did not identify one safe route. | Repair the resolver using live evidence; do not ask the caller for coordinates. | +| `extension_saved_state_prepare_protocol_unproven` | A needed saved-state part is missing and the reverse codec has not been proven for this layout. | Adapter protocol development and fixture testing. | +| Transport closure / timeout | Request lifecycle or deployment interruption. | Correlate REST and MCP audit events before diagnosing SQL. | +| `applied: true` | SQL reread matched the requested write. | Do not claim Configurator acceptance or activation without human confirmation. | + +## Non-negotiable rules + +- Keep extension scope exact. Same GUID/name in another extension is not permission to write there. +- Treat diagnostic SQL and raw file keys as adapter-private evidence, never as public API inputs. +- Log request ID, public selectors, resolver phase, safe state classification, duration, result, and sanitized failure details. Do not log secrets or raw content unnecessarily. +- Do not automate, emulate, or require Configurator. A human confirmation is the only evidence of Configurator visibility/activation. +- `upo_test` is the sole default mutation target. Treat `upo` as read-only unless the user explicitly authorizes a write. +- Update the API contract and runbook whenever public behavior, error meaning, or test coverage changes. + +## Known dead ends in this project + +- **Do not require a Configurator "initialisation" or another save because a target `ConfigCASSave` row is absent.** Missing rows are the normal first-write case handled inside the adapter. +- **Do not replace the proven `ConfigCAS → ConfigCASSave` copy route with an unconditional canonical-name map.** A live hash key is not itself a logical filename. Canonical names are permitted only after decoding the selected extension root and proving its exact `O` / `O.0` SHA-1 map and extension GUID. For that evidenced family, copy `root → E__configinfo`, `O → E__O`, and `O.0 → E__O.0` atomically; otherwise return `extension_saved_state_prepare_protocol_unproven`. +- **Do not copy active extension hash keys into `ConfigCASSave` under the same hash names and call it verified.** In `upo_test/фс_Отчеты` this passed SQL reread but Configurator continued to select `ConfigCAS`. SQL readback alone is not working-copy evidence. +- **Do not send an object-module canonical `.0` path back through generic metadata path decoding when a public object selector or concrete stream already resolved it.** It is a BSL container, not a metadata tree path; route it directly through the extension saved-state resolver. +- **Do not let a plan-only resolver redefine the write protocol.** `plan` may say preparation is needed; apply must execute the proven preparation route after its normal gate. +- **Do not make a client retry with a module reference or storage coordinates.** That only hides the adapter regression and breaks every consuming project. diff --git a/.codex/skills/onec-sql-extension-adapter/agents/openai.yaml b/.codex/skills/onec-sql-extension-adapter/agents/openai.yaml new file mode 100644 index 0000000..bbae459 --- /dev/null +++ b/.codex/skills/onec-sql-extension-adapter/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "1C SQL Adapter Development" + short_description: "Safe development of 1C SQL extension writes" + default_prompt: "Use $onec-sql-extension-adapter to diagnose or extend the adapter safely." diff --git a/.codex/skills/onec-sql-extension-adapter/references/protocol-and-test-matrix.md b/.codex/skills/onec-sql-extension-adapter/references/protocol-and-test-matrix.md new file mode 100644 index 0000000..c7f0bfb --- /dev/null +++ b/.codex/skills/onec-sql-extension-adapter/references/protocol-and-test-matrix.md @@ -0,0 +1,36 @@ +# Extension saved-state protocol and test matrix + +## Evidence threshold + +A writable extension path is proven only when all of the following are evidenced in a live test base: + +1. The public selector resolves to exactly one extension-owned object and module. +2. The active representation and extension layer are identified without cross-layer ambiguity. +3. The corresponding saved-state mapping is observed, including how an absent part is prepared. +4. The exact inverse codec is exercised on a controlled target. +5. Readback proves the requested text replacement. +6. `apply_and_rollback` restores the edited stream and removes every adapter-created overlay item. +7. Before enabling a new layout generally, a human confirms that Configurator displays the saved-state change. + +Never infer step 3 from an unrelated base object, a different extension, a filename convention, or an assumed `__configinfo` record. Raw saved-state keys may be hash-based or layout-specific. A hash key may be converted to a canonical saved-state name only when the selected active root itself has been decoded and contains the exact logical `O` / `O.0` to SHA-1 mapping. This is evidence for the three-file mapping, not a naming convention. + +## Minimum regression matrix + +| Case | What it proves | +|---|---| +| Base configuration saved write | Base `Config` to `ConfigSave`; not extension behavior. | +| Extension with an existing saved module | Read/write codec for an existing extension saved entry. | +| Extension module absent from saved state | Internal automatic preparation of a missing entry. | +| Hash-keyed active extension root that decodes to a logical file map | Create exactly `root → E__configinfo`, `O → E__O`, `O.0 → E__O.0`; preserve root service atoms verbatim. | +| Opaque hash-keyed extension layout | Return `extension_saved_state_prepare_protocol_unproven`; never insert same-name hash copies. | +| Canonical descriptor overlay, if evidenced | Use its paired-container codec only for that observed layout. | +| Extension with no usable saved overlay | Correct blocked result until a codec is proven; no caller workaround. | +| Duplicate-looking object in another extension | Exact extension isolation. | +| `apply_and_rollback` | Write, reread, rollback, and cleanup. | +| Docker replacement during a long call | Graceful request lifecycle and audit correlation. | + +## Safe public acceptance test + +The test request contains only public fields: base ID, extension name, object ref/name, module ordinal or unambiguous module selector, `old`, `new`, mode, and normal repository-coordination scope. It must not contain `ConfigCASSave`, `module_ref`, stream index, file key, table name, SQL text, or a serialization payload. + +Success means `applied: true` and a subsequent public search/read no longer finds `old` in the selected module. This proves SQL readback only. Configurator visibility requires separate human confirmation; activation/save remains outside this SQL-only adapter. diff --git a/AGENTS.md b/AGENTS.md index 4262fc4..5b83f5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,11 +16,30 @@ - For training/download containers on `docker-gpu`, sync the current repo into `Z:\LLM\model-chat-app` first. These containers should read code from the synced app directory, not directly from `Z:\codex\LLM`. - Do not store credentials, tokens, model secrets, or host passwords in repositories or project files. +## 1C SQL adapter deployment + +- Deploy the production/external 1C REST SQL adapter and `adapter-1c-mcp` on `docker.cin.su` (`192.168.200.85`), where Codex connects to `http://docker.cin.su:8021/mcp`. `test-docker` (`docker-test.cin.su`, `192.168.200.61`) is a separate staging host; do not substitute it for the external MCP endpoint unless the user explicitly requests staging. Do not deploy either service on `docker-gpu.cin.su`. +- The REST host port is configurable through `ADAPTER_1C_HOST_PORT`; use the same port in `ONEC_ADAPTER_URL` for MCP and agent services. +- The adapter SQL-base administration page is `http://docker.cin.su:/admin` (default: `http://docker.cin.su:8011/admin`). Use it to add or update live base connections; do not commit their SQL credentials or generated runtime configuration. +- Before deployment, inspect the selected port and container names. Do not stop, remove, recreate, or otherwise alter unrelated running containers. +- Keep live SQL connection settings in a non-committed deployment `.env` or mounted runtime configuration file. + ## `upo_test` mutation scope - `upo_test` is an isolated test infobase. The user has authorized full read/write adapter checks there, including controlled SQL saved-state changes and rollback. - This authorization does not turn an adapter-side marker into a native repository lock. When repository coordination is enabled, keep the configured request/confirmation scope and never claim automatic lock verification in the SQL-only adapter version. +## 1C adapter execution boundary + +- **Primary correctness rule:** the adapter never invents 1C metadata, payload fields, SQL routes, containers, signatures, or object relationships. It decodes only structures evidenced in live 1C SQL storage and encodes only through the corresponding proven reverse codec. +- If decoding is incomplete, a route is ambiguous, or a reverse encoding has not been proven, return an explicit `unresolved`/`unsupported`/`protocol_incomplete` result. Never substitute a plausible-looking structure or write guessed bytes. +- **Rule for every agent using the adapter:** never invent database structure, 1C objects, modules, forms, fields, SQL joins, BSL fragments, or target files. Before proposing or applying a change, obtain the exact object/module/field and current text or payload from the live base through the adapter. Treat any absent, ambiguous, or unverified item as unknown; report it and stop that branch rather than filling it in from names, conventions, or prior experience. +- The deployed 1C adapter works **only through SQL**. Its reads, saved-state preparation, and permitted writes use SQL tables such as `ConfigSave` and `ConfigCASSave`. +- A human operates Configurator in the adapter's normal workflow. The adapter must not start, automate, emulate, or require Designer/Configurator; do not propose or implement a Designer bridge unless the user explicitly changes this rule. +- A successful SQL write proves only SQL readback. Never claim that a change is visible, accepted, saved, activated, or validated by Configurator unless the human separately confirms it. +- For SQL structures whose complete Configurator protocol is not proven (including `__configinfo`), return an explicit unsupported/protocol-incomplete result. Do not fabricate containers, signatures, or activation evidence. +- These limits apply to the adapter product, not to its development. While creating, diagnosing, or testing the adapter, Codex may use any necessary authorised tools and evidence sources, including Configurator observation, test infobases, SQL, files, logs, and external research. Do not transfer that development capability into the adapter's runtime contract without explicit user approval. + ## Test-system security profile - This project currently runs as an isolated test system; use the minimum security profile unless the user explicitly requests production hardening. diff --git a/core/deploy/README.md b/core/deploy/README.md index c5d340a..fcd7218 100644 --- a/core/deploy/README.md +++ b/core/deploy/README.md @@ -26,10 +26,10 @@ docs/runbooks/adapter-1c-mcp.md core/deploy/docker/adapter-1c-mcp/compose.yaml ``` -1C REST adapter on GPU host: +1C REST SQL adapter on `docker.cin.su`: ```text -core/deploy/docker-gpu/adapter-1c/compose.yaml +core/deploy/docker/adapter-1c/compose.yaml ``` 1C agent service (подпроект): @@ -40,6 +40,7 @@ docs/runbooks/1c-agent.md ``` The current container is read-first and route-index backed. It serves -`http://docker-gpu.cin.su:8011`, keeps the route index in the +`http://docker.cin.su:8011` by default (the host port is configurable through +`ADAPTER_1C_HOST_PORT`), keeps the route index in the `adapter-1c_adapter-1c-data` Docker volume, and is used by `adapter-1c-mcp` through `ONEC_ADAPTER_URL`. diff --git a/core/deploy/docker-gpu/README.md b/core/deploy/docker-gpu/README.md index 05e374d..924dcce 100644 --- a/core/deploy/docker-gpu/README.md +++ b/core/deploy/docker-gpu/README.md @@ -5,3 +5,24 @@ Здесь будут находиться compose-файлы, env-шаблоны и инструкции для запуска GPU-сервисов. Секреты должны передаваться через окружение, секрет-хранилище или настройки хоста, но не через git. + +Для 1C-адаптера `adapter-1c-audit` запускается рядом с REST-сервисом и раз в +15 минут записывает безопасную сводку журнала в том `adapter-1c-data`: +`/data/adapter-audit-reports/latest.json`. Период задаётся +`ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS`; журнал не содержит BSL-текстов, +SQL-полезной нагрузки или секретов. + +Безопасное обновление полного 1C-стека выполняется из корня репозитория: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\deploy_1c_adapter_stack.ps1 -SkipVerify +``` + +Этот каталог предназначен только для GPU-нагрузок. SQL-адаптер 1С и MCP +разворачиваются на `docker.cin.su` из `core/deploy/docker/adapter-1c/` и +`core/deploy/docker/adapter-1c-mcp/`. После обновления проверьте REST +`http://docker.cin.su:8011/health?base_id=upo_test` и MCP +`http://docker.cin.su:8021/health`. + +CPU-only endpoint эмбеддингов для актуального поиска по коду 1С находится в +`embeddings/`; он разворачивается на GPU-хосте, но намеренно не резервирует GPU. diff --git a/core/deploy/docker-gpu/adapter-1c/compose.yaml b/core/deploy/docker-gpu/adapter-1c/compose.yaml index 24cc158..eb2f20e 100644 --- a/core/deploy/docker-gpu/adapter-1c/compose.yaml +++ b/core/deploy/docker-gpu/adapter-1c/compose.yaml @@ -1,3 +1,5 @@ +# Legacy location. Deploy the REST SQL adapter from +# core/deploy/docker/adapter-1c/compose.yaml to docker.cin.su. name: adapter-1c services: @@ -8,6 +10,7 @@ services: image: ${ADAPTER_1C_IMAGE:-adapter-1c-rest:latest} container_name: ${ADAPTER_1C_CONTAINER_NAME:-adapter-1c-rest} restart: unless-stopped + stop_grace_period: 5m ports: - "${ADAPTER_1C_HOST_PORT:-8011}:8011" volumes: @@ -19,6 +22,20 @@ services: ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN: ${ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN:-true} ONEC_SQL_BASES_JSON: ${ONEC_SQL_BASES_JSON:-} ONEC_SQL_BASES_JSON_FILE: ${ONEC_SQL_BASES_JSON_FILE:-/data/onec-sql-bases.json} + ONEC_ADAPTER_CACHE_DB: ${ONEC_ADAPTER_CACHE_DB:-/data/adapter-cache.sqlite} + ONEC_ADAPTER_STATE_DB: ${ONEC_ADAPTER_STATE_DB:-/data/adapter-cache.sqlite} + ONEC_ADAPTER_JOB_STORE: ${ONEC_ADAPTER_JOB_STORE:-/data/adapter-jobs.json} + ONEC_REPOSITORY_STATE_FILE: ${ONEC_REPOSITORY_STATE_FILE:-/data/onec-repository-locks.json} + ONEC_ADAPTER_BACKUP_DIR: ${ONEC_ADAPTER_BACKUP_DIR:-/data/adapter-apply-backups} + ONEC_CONFIGURATION_ACTIVATION_STATE_FILE: ${ONEC_CONFIGURATION_ACTIVATION_STATE_FILE:-/data/onec-configuration-activation-requests.json} + ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS: ${ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS:-1800} + ONEC_ADAPTER_JOB_TIMEOUT_SECONDS: ${ONEC_ADAPTER_JOB_TIMEOUT_SECONDS:-240} + ONEC_ADAPTER_FULL_TIMEOUT_SECONDS: ${ONEC_ADAPTER_FULL_TIMEOUT_SECONDS:-600} + ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS: ${ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS:-180} + ONEC_ADAPTER_JOB_PROCESS_ISOLATION: ${ONEC_ADAPTER_JOB_PROCESS_ISOLATION:-true} + ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB: ${ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB:-0} + ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS: ${ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS:-0} + ONEC_ADAPTER_DEBUG_DIAGNOSTICS: ${ONEC_ADAPTER_DEBUG_DIAGNOSTICS:-false} ONEC_INFOBASE_USER_ADMIN_BASES_JSON: ${ONEC_INFOBASE_USER_ADMIN_BASES_JSON:-} ONEC_INFOBASE_USER_ADMIN_BASES_JSON_FILE: ${ONEC_INFOBASE_USER_ADMIN_BASES_JSON_FILE:-/data/onec-infobase-user-admin.json} ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST: ${ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST:-} @@ -28,5 +45,21 @@ services: ONEC_REPOSITORY_REQUEST_TTL_SECONDS: ${ONEC_REPOSITORY_REQUEST_TTL_SECONDS:-86400} ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS: ${ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS:-7200} + adapter-1c-audit: + image: ${ADAPTER_1C_IMAGE:-adapter-1c-rest:latest} + container_name: ${ADAPTER_1C_AUDIT_CONTAINER_NAME:-adapter-1c-audit} + restart: unless-stopped + depends_on: + - adapter-1c-rest + volumes: + - adapter-1c-data:/data + environment: + ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS: ${ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS:-900} + command: >- + sh -c 'mkdir -p /data/adapter-audit-reports; + while true; do python /app/analyze_audit.py --log /data/adapter-audit.jsonl > /data/adapter-audit-reports/latest.json.tmp + && mv /data/adapter-audit-reports/latest.json.tmp /data/adapter-audit-reports/latest.json; + sleep "$${ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS}"; done' + volumes: adapter-1c-data: diff --git a/core/deploy/docker-gpu/embeddings/.env.example b/core/deploy/docker-gpu/embeddings/.env.example new file mode 100644 index 0000000..393b81d --- /dev/null +++ b/core/deploy/docker-gpu/embeddings/.env.example @@ -0,0 +1,18 @@ +# CPU-only OpenAI-compatible embedding endpoint for 1C code search. +# The model cache is persistent on docker-gpu; no credentials are required. + +EMBEDDING_CONTAINER_NAME=llm-qwen3-embedding +EMBEDDING_IMAGE=ghcr.io/ggml-org/llama.cpp@sha256:3e8914c1aab600a330ada97c10fb5fb02ff1c15ac39f2ece218db125cf54594e +EMBEDDING_HOST_PORT=8082 +EMBEDDING_HF_REPO=Qwen/Qwen3-Embedding-0.6B-GGUF:Q8_0 +EMBEDDING_SERVED_MODEL_NAME=qwen3-embedding-0.6b +EMBEDDING_CTX_SIZE=8192 +EMBEDDING_BATCH_SIZE=1024 +EMBEDDING_UBATCH_SIZE=1024 +EMBEDDING_THREADS=12 +EMBEDDING_PARALLEL=1 +EMBEDDING_CPU_LIMIT=8.0 +EMBEDDING_MEMORY_LIMIT=4G + +# Windows path on docker-gpu.cin.su, mounted into the Linux container. +HOST_LLAMA_CACHE_DIR=Z:/LLM/models/cache/llama.cpp diff --git a/core/deploy/docker-gpu/embeddings/compose.yaml b/core/deploy/docker-gpu/embeddings/compose.yaml new file mode 100644 index 0000000..d54b4cf --- /dev/null +++ b/core/deploy/docker-gpu/embeddings/compose.yaml @@ -0,0 +1,51 @@ +name: llm-embeddings + +services: + qwen3-embedding: + image: ${EMBEDDING_IMAGE:-ghcr.io/ggml-org/llama.cpp@sha256:3e8914c1aab600a330ada97c10fb5fb02ff1c15ac39f2ece218db125cf54594e} + container_name: ${EMBEDDING_CONTAINER_NAME:-llm-qwen3-embedding} + restart: unless-stopped + ports: + - "${EMBEDDING_HOST_PORT:-8082}:8080" + volumes: + - ${HOST_LLAMA_CACHE_DIR:-Z:/LLM/models/cache/llama.cpp}:/root/.cache/llama.cpp + command: + - --host + - 0.0.0.0 + - --port + - "8080" + - --hf-repo + - ${EMBEDDING_HF_REPO:-Qwen/Qwen3-Embedding-0.6B-GGUF:Q8_0} + - --alias + - ${EMBEDDING_SERVED_MODEL_NAME:-qwen3-embedding-0.6b} + - --embedding + - --pooling + - last + - --ctx-size + - ${EMBEDDING_CTX_SIZE:-8192} + - --batch-size + - ${EMBEDDING_BATCH_SIZE:-1024} + - --ubatch-size + - ${EMBEDDING_UBATCH_SIZE:-1024} + - --threads + - ${EMBEDDING_THREADS:-12} + - --parallel + - ${EMBEDDING_PARALLEL:-1} + - --n-gpu-layers + - "0" + healthcheck: + test: + - CMD + - curl + - --fail + - --silent + - http://localhost:8080/health + interval: 10s + timeout: 5s + retries: 30 + start_period: 10m + deploy: + resources: + limits: + cpus: ${EMBEDDING_CPU_LIMIT:-8.0} + memory: ${EMBEDDING_MEMORY_LIMIT:-4G} diff --git a/core/deploy/docker/1c-agent/1c-agent.env.example b/core/deploy/docker/1c-agent/1c-agent.env.example index cfa75d8..1b2d784 100644 --- a/core/deploy/docker/1c-agent/1c-agent.env.example +++ b/core/deploy/docker/1c-agent/1c-agent.env.example @@ -3,7 +3,11 @@ ONEC_AGENT_CONTAINER_NAME=onec-agent ONEC_AGENT_HOST_PORT=8090 ONEC_AGENT_DEFAULT_BASE_URL=http://docker-gpu.cin.su:8000 ONEC_AGENT_DEFAULT_MODEL=qwen3-4b-instruct-2507 -ONEC_ADAPTER_URL=http://docker-gpu.cin.su:8011 +ONEC_MCP_URL=http://docker.cin.su:8021 + +# Keep false for ordinary coding agents; diagnostic storage routes stay in the +# developer adapter/MCP surface. +ONEC_AGENT_ALLOW_DIAGNOSTIC=false # Для интеграции с несколькими ИИ провайдерскими конечными точками # Формат JSON: diff --git a/core/deploy/docker/1c-agent/compose.yaml b/core/deploy/docker/1c-agent/compose.yaml index a51d2c1..3321530 100644 --- a/core/deploy/docker/1c-agent/compose.yaml +++ b/core/deploy/docker/1c-agent/compose.yaml @@ -18,8 +18,8 @@ services: ONEC_AGENT_DB_PATH: /app/data/onec-agent.db ONEC_AGENT_DEFAULT_BASE_URL: ${ONEC_AGENT_DEFAULT_BASE_URL:-http://docker-gpu.cin.su:8000} ONEC_AGENT_DEFAULT_MODEL: ${ONEC_AGENT_DEFAULT_MODEL:-qwen3-4b-instruct-2507} - ONEC_ADAPTER_URL: ${ONEC_ADAPTER_URL:-http://docker-gpu.cin.su:8011} - ONEC_ADAPTER_TOKEN: ${ONEC_ADAPTER_TOKEN:-} + ONEC_MCP_URL: ${ONEC_MCP_URL:-http://docker.cin.su:8021} + ONEC_AGENT_ALLOW_DIAGNOSTIC: ${ONEC_AGENT_ALLOW_DIAGNOSTIC:-false} ONEC_AGENT_PROVIDERS: ${ONEC_AGENT_PROVIDERS:-} volumes: diff --git a/core/deploy/docker/adapter-1c-mcp/.env.example b/core/deploy/docker/adapter-1c-mcp/.env.example index d644e93..79c9f74 100644 --- a/core/deploy/docker/adapter-1c-mcp/.env.example +++ b/core/deploy/docker/adapter-1c-mcp/.env.example @@ -4,8 +4,12 @@ ADAPTER_1C_MCP_HOST_PORT=8021 # REST 1C adapter endpoint. The MCP proxy forwards onec_request(method,payload) # to this service. Change it when the real adapter container is deployed. -ONEC_ADAPTER_URL=http://docker-gpu.cin.su:8011 +ONEC_ADAPTER_URL=http://docker.cin.su:8011 ONEC_ADAPTER_TIMEOUT_SECONDS=120 +# Keep false for ordinary coding agents. Enable only in a developer-only MCP +# deployment that is allowed to expose storage diagnostics. +ONEC_MCP_ALLOW_DIAGNOSTIC=false + # Optional bearer token for the REST adapter. Do not commit real secrets. ONEC_ADAPTER_TOKEN= diff --git a/core/deploy/docker/adapter-1c-mcp/compose.yaml b/core/deploy/docker/adapter-1c-mcp/compose.yaml index 4c833d0..07ecb47 100644 --- a/core/deploy/docker/adapter-1c-mcp/compose.yaml +++ b/core/deploy/docker/adapter-1c-mcp/compose.yaml @@ -12,6 +12,29 @@ services: - "${ADAPTER_1C_MCP_HOST_PORT:-8021}:8021" environment: PORT: "8021" - ONEC_ADAPTER_URL: ${ONEC_ADAPTER_URL:-http://docker-gpu.cin.su:8011} + ONEC_ADAPTER_URL: ${ONEC_ADAPTER_URL:-http://docker.cin.su:8011} ONEC_ADAPTER_TOKEN: ${ONEC_ADAPTER_TOKEN:-} ONEC_ADAPTER_TIMEOUT_SECONDS: ${ONEC_ADAPTER_TIMEOUT_SECONDS:-240} + ONEC_MCP_ALLOW_DIAGNOSTIC: ${ONEC_MCP_ALLOW_DIAGNOSTIC:-false} + ONEC_MCP_DEBUG_DIAGNOSTICS: ${ONEC_MCP_DEBUG_DIAGNOSTICS:-false} + volumes: + - adapter-1c-mcp-data:/data + + adapter-1c-mcp-audit: + image: ${ADAPTER_1C_MCP_IMAGE:-adapter-1c-mcp:latest} + container_name: ${ADAPTER_1C_MCP_AUDIT_CONTAINER_NAME:-adapter-1c-mcp-audit} + restart: unless-stopped + depends_on: + - adapter-1c-mcp + volumes: + - adapter-1c-mcp-data:/data + environment: + ONEC_MCP_AUDIT_INTERVAL_SECONDS: ${ONEC_MCP_AUDIT_INTERVAL_SECONDS:-900} + command: >- + sh -c 'mkdir -p /data/adapter-audit-reports; + while true; do python /app/analyze_audit.py > /data/adapter-audit-reports/latest.json.tmp + && mv /data/adapter-audit-reports/latest.json.tmp /data/adapter-audit-reports/latest.json; + sleep "$${ONEC_MCP_AUDIT_INTERVAL_SECONDS}"; done' + +volumes: + adapter-1c-mcp-data: diff --git a/core/deploy/docker/adapter-1c/.env.example b/core/deploy/docker/adapter-1c/.env.example new file mode 100644 index 0000000..3070ae9 --- /dev/null +++ b/core/deploy/docker/adapter-1c/.env.example @@ -0,0 +1,20 @@ +ADAPTER_1C_IMAGE=adapter-1c-rest:latest +ADAPTER_1C_CONTAINER_NAME=adapter-1c-rest + +# Public REST port on docker.cin.su. Change it if 8011 is occupied, then use +# the same value in ONEC_ADAPTER_URL for MCP and the 1C agent. +ADAPTER_1C_HOST_PORT=8011 + +# Live SQL connections. Keep real credentials outside git. +# Example: +# ONEC_SQL_BASES_JSON={"upo_test":{"server":"sql-host","database":"upo_test","user":"configured_login","password_env":"ONEC_SQL_PASSWORD_UPO_TEST"}} +# ONEC_SQL_PASSWORD_UPO_TEST=put-this-only-in-a-real-non-committed-env-file +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/core/deploy/docker/adapter-1c/compose.yaml b/core/deploy/docker/adapter-1c/compose.yaml new file mode 100644 index 0000000..f50dc10 --- /dev/null +++ b/core/deploy/docker/adapter-1c/compose.yaml @@ -0,0 +1,63 @@ +name: adapter-1c + +services: + adapter-1c-rest: + build: + context: ../../../../plugins/1c + dockerfile: connector/Dockerfile + image: ${ADAPTER_1C_IMAGE:-adapter-1c-rest:latest} + container_name: ${ADAPTER_1C_CONTAINER_NAME:-adapter-1c-rest} + restart: unless-stopped + stop_grace_period: 5m + ports: + - "${ADAPTER_1C_HOST_PORT:-8011}:8011" + volumes: + - adapter-1c-data:/data + environment: + ONEC_ADAPTER_HOST: 0.0.0.0 + ONEC_ADAPTER_PORT: 8011 + ONEC_ADAPTER_SERVICE_TOKEN: ${ONEC_ADAPTER_SERVICE_TOKEN:-} + ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN: ${ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN:-true} + ONEC_SQL_BASES_JSON: ${ONEC_SQL_BASES_JSON:-} + ONEC_SQL_BASES_JSON_FILE: ${ONEC_SQL_BASES_JSON_FILE:-/data/onec-sql-bases.json} + ONEC_ADAPTER_CACHE_DB: ${ONEC_ADAPTER_CACHE_DB:-/data/adapter-cache.sqlite} + ONEC_ADAPTER_STATE_DB: ${ONEC_ADAPTER_STATE_DB:-/data/adapter-cache.sqlite} + ONEC_ADAPTER_JOB_STORE: ${ONEC_ADAPTER_JOB_STORE:-/data/adapter-jobs.json} + ONEC_REPOSITORY_STATE_FILE: ${ONEC_REPOSITORY_STATE_FILE:-/data/onec-repository-locks.json} + ONEC_ADAPTER_BACKUP_DIR: ${ONEC_ADAPTER_BACKUP_DIR:-/data/adapter-apply-backups} + ONEC_CONFIGURATION_ACTIVATION_STATE_FILE: ${ONEC_CONFIGURATION_ACTIVATION_STATE_FILE:-/data/onec-configuration-activation-requests.json} + ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS: ${ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS:-1800} + ONEC_ADAPTER_JOB_TIMEOUT_SECONDS: ${ONEC_ADAPTER_JOB_TIMEOUT_SECONDS:-240} + ONEC_ADAPTER_FULL_TIMEOUT_SECONDS: ${ONEC_ADAPTER_FULL_TIMEOUT_SECONDS:-600} + ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS: ${ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS:-180} + ONEC_ADAPTER_JOB_PROCESS_ISOLATION: ${ONEC_ADAPTER_JOB_PROCESS_ISOLATION:-true} + ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB: ${ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB:-0} + ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS: ${ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS:-0} + ONEC_ADAPTER_DEBUG_DIAGNOSTICS: ${ONEC_ADAPTER_DEBUG_DIAGNOSTICS:-false} + ONEC_INFOBASE_USER_ADMIN_BASES_JSON: ${ONEC_INFOBASE_USER_ADMIN_BASES_JSON:-} + ONEC_INFOBASE_USER_ADMIN_BASES_JSON_FILE: ${ONEC_INFOBASE_USER_ADMIN_BASES_JSON_FILE:-/data/onec-infobase-user-admin.json} + ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST: ${ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST:-} + ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED: ${ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED:-false} + ONEC_REPOSITORY_RUNNER_TOKEN: ${ONEC_REPOSITORY_RUNNER_TOKEN:-} + ONEC_ADAPTER_ENABLE_EXTERNAL_1C: ${ONEC_ADAPTER_ENABLE_EXTERNAL_1C:-false} + ONEC_REPOSITORY_REQUEST_TTL_SECONDS: ${ONEC_REPOSITORY_REQUEST_TTL_SECONDS:-86400} + ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS: ${ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS:-7200} + + adapter-1c-audit: + image: ${ADAPTER_1C_IMAGE:-adapter-1c-rest:latest} + container_name: ${ADAPTER_1C_AUDIT_CONTAINER_NAME:-adapter-1c-audit} + restart: unless-stopped + depends_on: + - adapter-1c-rest + volumes: + - adapter-1c-data:/data + environment: + ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS: ${ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS:-900} + command: >- + sh -c 'mkdir -p /data/adapter-audit-reports; + while true; do python /app/analyze_audit.py --log /data/adapter-audit.jsonl > /data/adapter-audit-reports/latest.json.tmp + && mv /data/adapter-audit-reports/latest.json.tmp /data/adapter-audit-reports/latest.json; + sleep "$${ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS}"; done' + +volumes: + adapter-1c-data: diff --git a/core/deploy/docker/adapter-observer/.env.example b/core/deploy/docker/adapter-observer/.env.example new file mode 100644 index 0000000..66d0734 --- /dev/null +++ b/core/deploy/docker/adapter-observer/.env.example @@ -0,0 +1,12 @@ +ADAPTER_OBSERVER_IMAGE=adapter-observer:latest +ADAPTER_OBSERVER_CONTAINER_NAME=adapter-observer +ADAPTER_OBSERVER_HOST_PORT=8031 +# Only needed for a copied deployment package; repository deployment uses default. +# ADAPTER_OBSERVER_BUILD_CONTEXT=./observer +# Existing adapter volume: mount is read-only in this service. +ADAPTER_OBSERVER_AUDIT_VOLUME=adapter-1c_adapter-1c-data +ADAPTER_OBSERVER_MCP_AUDIT_VOLUME=adapter-1c-mcp_adapter-1c-mcp-data +ADAPTER_OBSERVER_ADAPTER_NETWORK=adapter-1c_default +ONEC_OBSERVER_ADAPTER_URL=http://adapter-1c-rest:8011 +ONEC_OBSERVER_COVERAGE_BASE_ID=upo_test +ONEC_OBSERVER_COVERAGE_INTERVAL_SECONDS=900 diff --git a/core/deploy/docker/adapter-observer/compose.yaml b/core/deploy/docker/adapter-observer/compose.yaml new file mode 100644 index 0000000..53f1a42 --- /dev/null +++ b/core/deploy/docker/adapter-observer/compose.yaml @@ -0,0 +1,42 @@ +name: adapter-observer + +services: + adapter-observer: + build: + context: ${ADAPTER_OBSERVER_BUILD_CONTEXT:-../../../../plugins/1c/observer} + dockerfile: Dockerfile + image: ${ADAPTER_OBSERVER_IMAGE:-adapter-observer:latest} + container_name: ${ADAPTER_OBSERVER_CONTAINER_NAME:-adapter-observer} + restart: unless-stopped + ports: + - "${ADAPTER_OBSERVER_HOST_PORT:-8031}:8031" + volumes: + - adapter-1c-data:/audit:ro + - adapter-1c-mcp-data:/mcp-audit:ro + - adapter-observer-state:/state + environment: + ONEC_OBSERVER_HOST: 0.0.0.0 + ONEC_OBSERVER_PORT: 8031 + ONEC_OBSERVER_AUDIT_DIR: /audit + ONEC_OBSERVER_MCP_AUDIT_DIR: /mcp-audit + ONEC_OBSERVER_STATE_DIR: /state + ONEC_OBSERVER_ADAPTER_URL: ${ONEC_OBSERVER_ADAPTER_URL:-http://adapter-1c-rest:8011} + ONEC_OBSERVER_COVERAGE_BASE_ID: ${ONEC_OBSERVER_COVERAGE_BASE_ID:-upo_test} + ONEC_OBSERVER_COVERAGE_INTERVAL_SECONDS: ${ONEC_OBSERVER_COVERAGE_INTERVAL_SECONDS:-900} + networks: + - default + - adapter-1c + +volumes: + adapter-1c-data: + external: true + name: ${ADAPTER_OBSERVER_AUDIT_VOLUME:-adapter-1c_adapter-1c-data} + adapter-1c-mcp-data: + external: true + name: ${ADAPTER_OBSERVER_MCP_AUDIT_VOLUME:-adapter-1c-mcp_adapter-1c-mcp-data} + adapter-observer-state: + +networks: + adapter-1c: + external: true + name: ${ADAPTER_OBSERVER_ADAPTER_NETWORK:-adapter-1c_default} diff --git a/docs/1c-adapter-api-contract.md b/docs/1c-adapter-api-contract.md index 57c5d5f..3e44792 100644 --- a/docs/1c-adapter-api-contract.md +++ b/docs/1c-adapter-api-contract.md @@ -2,22 +2,18 @@ ## Configuration repository control -The current adapter release is SQL-only. It does not start Designer, call a -Windows runner, or inspect repository internals. External 1C execution is a -future-version capability and is disabled by default with -`ONEC_ADAPTER_ENABLE_EXTERNAL_1C=false`. +The adapter is SQL-only. It does not start, automate, emulate, or require +Designer/Configurator. Configurator actions are performed by a human and are +outside the adapter's execution boundary. Repository operations are available through `repository.status`, `repository.lock.plan`, `repository.lock`, `repository.lock.confirm`, `repository.lock.verify`, `repository.unlock`, `repository.commit.plan`, and `repository.commit`. -Configuration is selected only by `payload.base_id`: the base runtime profile -declares `repository.backend=direct|karman_bridge`, the Designer executable, -infobase selector, endpoint, optional extension, users, and environment-variable -names containing transient passwords. No repository, bridge, or endpoint name is -hard-coded or inferred from naming conventions. +Configuration is selected only by `payload.base_id`. No Designer executable, +Configurator endpoint, bridge, or external 1C runner is used by this adapter. -`repository.lock_mode=automatic|manual` is also selected per base. Automatic -mode uses the configured runner. Manual mode requires no Designer or runner: +`repository.lock_mode=manual` is selected per base. Manual mode requires no +Designer integration: `repository.lock.plan` returns the exact public development-object names to lock in Configurator, and `repository.lock.confirm` records the user's explicit confirmation for only that object set. Such a session is marked @@ -43,13 +39,11 @@ further SQL writes through that session. The adapter keeps a bounded audit trail of request creation, confirmation, cancellation, and closure and exposes it to the administrative requests view. -Both backends invoke standard Designer repository commands. A Karman/Filebox -backend is an opaque native TCP transport and does not own credentials, object -locks, or repository transactions. For configured bases, saved-state apply is -blocked until the caller supplies an active adapter-owned `lock_session_id`. -Commit additionally requires `allow_repository_commit=true` and a non-empty -version comment. Unlock and commit operate only on the object set recorded for -that adapter session. +Repository coordination records only the human-confirmed scope. It does not +invoke Designer repository commands and does not prove a native lock. For +configured bases, saved-state apply is blocked until the caller supplies an +active adapter-owned `lock_session_id`; that session is coordination evidence, +not a native repository transaction. Status: draft, read-only first. @@ -64,6 +58,30 @@ Related work plan: `docs/1c-extension-layer-plan.md`. - When XML-derived rules are promoted into the adapter, the runtime write path must still resolve to concrete SQL storage targets such as `ConfigSave` or `ConfigCASSave`, with explicit gates and readback verification. +- SQL readback is not proof that Configurator accepts, displays, saves, or + activates a change. Only a human may provide that confirmation. +- The adapter must reject, rather than fabricate, a `ConfigSave`/ + `ConfigCASSave` container or signature whose SQL protocol is not proven. + +## Data Composition Schema Query Writes + +`scd.patch` changes only one proven direct XML scalar: a dataset `query`, a +calculated-field `expression`, or a resource `expression`. It resolves the +report and SCD by public 1C names, preserves the surrounding SQL payload bytes +and compression envelope, and never accepts a storage key from a caller. + +For `execution_mode=apply`, `apply_and_verify`, or `apply_and_rollback`, a +missing saved SCD payload is prepared internally through the established +saved-state copy codec when `allow_sql_saved_state_prepare=true` is supplied. +The caller still explicitly authorizes the eventual edit with +`allow_saved_state_write=true` and `allow_sql_saved_state_apply=true`. + +`apply_and_rollback` additionally requires +`allow_sql_saved_state_rollback=true`. It restores the exact apply backup, not +a newly encoded copy of the old query. If this request created the saved-state +overlay, it then removes only receipt-recorded rows after hash precondition +checks. A successful result proves SQL readback and cleanup only; it does not +prove Configurator visibility, acceptance, or activation. ## User Identity And Access Terminology @@ -123,8 +141,8 @@ Password mutation is available only for the platform `infobase_user` layer: `allow_administrator_password_change=true`; - normally both mutations are blocked when `ONEC_ADAPTER_SERVICE_TOKEN` is empty. A disposable isolated test stand may explicitly set - `ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED=true`; this also permits an - unprotected runtime bridge endpoint and must never be enabled in production. + `ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED=true`; it affects only this + isolated SQL operation and must never be enabled in production. Both operations select the exact user through `infobase.user.get` and update only `dbo.v8users.Data`. The clear-text password for `set` exists only in memory @@ -411,6 +429,64 @@ Purpose: Normal coding agents should write BSL through `code.write`, not through SQL, storage rows, payload paths, or `metadata.module.write_apply`. +### Working-state and saved-state ownership rule + +An agent works only with the effective working configuration. It reads the +saved development version when one exists and otherwise reads active code; it +does not determine whether `ConfigSave` or `ConfigCASSave` exists, and it must +not call a separate saved-state preparation operation as part of a normal code +change. + +The adapter owns that transition. A `concrete_reference` returned by +`code.search` or `code.read` is an opaque proof of the exact active module +stream, extension, and layer—not permission to write active storage. After +preflight has resolved that reference, a normal apply must do the following +inside the adapter: + +`code.write` accepts the same reference either directly or inside +`write_selector.target`. It verifies the supplied public `ref`, extension, +layer, canonical path, and routine against that exact stream, then routes it +to the saved copy without repeating `metadata.object.modules` discovery. + +For one apply request the adapter authorizes the public extension scope once +before preparation. Its internal prepare and writer stages reuse that +process-bound authorization; they must not repeat broad support/owner scans +against `ConfigCASSave`. If authorization is absent, the request returns a +bounded `blocked` result before any support scan or saved-state mutation. + +For a client transport deadline shorter than the adapter write budget, call +`adapter.job.start` with `method="code.write"` and the same public payload, +then poll `adapter.job.get`. This preserves the final structured write result; +the caller must not retry the SQL write while the job is running. + +1. verify the exact extension/layer and active source stream; +2. create a missing saved-state copy through the proven + `Config`/`ConfigCAS` to `ConfigSave`/`ConfigCASSave` copy path; +3. apply the guarded edit only to saved state; +4. make a backup, validate BSL and the old-text/SHA preconditions, and reread + the written result; and +5. support the requested rollback, including removal or restoration of any + adapter-created saved-state copy. + +`metadata.write.preflight` reports such a first write as `status=ready` with +`route.preparation.status=adapter_managed`, +`route.preparation.caller_action_required=false`, and +`saved_state.status=will_prepare`. `needs_prepare` is reserved for a route +whose copy protocol is not sufficiently proven for the adapter to execute. +The adapter must never write the active `Config` or `ConfigCAS` layer. + +For a hash-keyed extension overlay, this internal copy includes the live +extension root manifest (`root_cas_key`) and the confirmed object parts. An +exact BSL stream reference remains the write target, but must not reduce the +saved-state preparation to that leaf stream alone. + +The caller never invokes or waits for this preparation as a separate workflow: +`code.write` performs the bounded copy, write, verification, and readback as +one adapter operation. Its preparation gate is restricted to the confirmed +extension layer and public object; it does not perform a whole-extension +support scan before copying proven source parts. + + `code.write` accepts 1C names and code text: ```json @@ -428,11 +504,21 @@ storage rows, payload paths, or `metadata.module.write_apply`. Contract: -- default `mode` is `apply`, and apply means save to the working - `ConfigSave`/`ConfigCASSave` layer, not production apply; +- default `mode` is `plan` and does not write SQL. `apply`, + `apply_and_verify`, and `apply_and_rollback` must be requested explicitly; + they save only to the working `ConfigSave`/`ConfigCASSave` layer, not to + production; - every `code.write` response includes `write_mode.target=saved_state`, `write_mode.activation_state=not_activated`, and `write_mode.production_apply=false`; +- the normal agent view is the effective working configuration: a matching + `ConfigSave`/`ConfigCASSave` overlay is read ahead of active code. Agents + do not inspect, create, or select saved-state rows. For a first extension + write, the adapter takes the exact active module handle returned by + `code.search`/`code.read`, creates the proven `ConfigCASSave` copy inside + its guarded apply workflow, and then edits that copy. Preflight reports this + as `route.preparation.status=adapter_managed`; no separate prepare call is + required from the agent. - saved-state `code.read` and `code.search` responses include `current_state.source=saved_state` and `current_state.activation_state=not_activated`; @@ -455,7 +541,9 @@ Contract: For embedded form modules the adapter writes only the scalar module token in the saved form payload with `path_preserve_format`. Whole-form payload canonicalization is forbidden because Designer may reject the form even if the -payload decoder can parse it. +payload decoder can parse it. A write plan for such a container returns a ready +`code.write` apply hint rather than asking the caller to invent a +`#stream:`. ## Resolve Object @@ -601,6 +689,22 @@ Important methods: Constants are exposed as a typed `value`; enumeration rows include their public value `name`, `synonym`, and `value_ref`. Business-process storage is resolved through the platform `_BPr` route internally. +- `additional_attributes.find` is the read-only entry point for additional + requisites. It reads a public `ChartOfCharacteristicTypes` selector (by + default `ДополнительныеРеквизитыИСведения`) and accepts `query` for a + description search. Its `empty_source` result is deliberately distinct from + `not_found`: it means that the resolved chart route has no records in the + selected infobase, so no property reference or value type can be claimed. + `additional_attributes.storage.resolve` requires a confirmed 32-hex + `property_ref` plus `owner_ref`. It searches candidate information registers + and returns a storage join only after live metadata proves dimensions + `Объект` and `Свойство`, a `Значение` resource, and that `Свойство` is a + reference to the selected PВХ. Otherwise it returns `unresolved` and never + guesses physical `_Fld...` columns. + For `ChartOfCharacteristicTypes`, `data.schema` and the find result also + expose chart-level `allowed_value_types` with its live Config evidence path. + This is explicitly not represented as the type of an individual property + record until that record's `ТипЗначения` route is decoded. - `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 @@ -728,6 +832,10 @@ Important methods: `Модуль сервиса интеграции`. Handler names decoded by `metadata.object.properties` can therefore be followed directly into their live SQL module routines. +- Exact extension objects are resolved by the same public `kind` + `name` or + `ref` selectors as base objects. `metadata.object.modules` also returns form + modules owned by the selected object, with qualified 1C names and without + exposing CAS keys unless `include_storage=true`. - The same module APIs expose the four configuration-level modules through the public `Configuration.` selector: ordinary application, external connection, managed application, and session. Runtime discovery reads the @@ -859,6 +967,15 @@ Important methods: `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. +- `metadata.module_owner_cache.backfill` incrementally builds the reverse + `module_ref -> 1C object` map from current metadata. One call processes a + bounded object page and returns `next_cursor` (`kind_index`, `kind`, + `offset`) until `complete=true`. The discovered public owner is propagated + to existing lexical/vector code-index rows, including the matching + active/saved-state table pair. By default objects without code-index rows + are skipped so the operation remains fast; `deep=true` explicitly enables + their slower metadata-module decoding. This changes adapter-local SQLite + only. - 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 @@ -905,7 +1022,11 @@ Important methods: code fragment. `state` is passed through to `modules.search`; the MCP `source_state=working` policy maps to this `state=working` mode. - `code.read`: wraps module/routine reads for agent-facing code analysis. It - may set `source.kind=code_read`, but it must preserve the module `origin` + accepts either `routine_name` or the inclusive `line_start`/`line_end` range. + A focused request may use a previously decoded local code-index snapshot + (`source.kind=code_index_cache`, `freshness.verified_against_sql=false`) to + avoid decoding a large container again; pass `prefer_code_index=false` for + the normal live SQL decode path. It must preserve the module `origin` 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 @@ -1000,6 +1121,9 @@ Working source state: `comparison.both_present` plus `comparison.differs`. When `include_text=true`, top-level `text` is the effective programming text: saved-state text if it exists, otherwise active text. `text_source` names the layer used. + For extension form modules, the saved counterpart is matched by extension, + logical form owner, and form GUID rather than by assuming identical active + and saved CAS file names. - `code.search state=both` also returns a mixed view for saved CommonForm code: saved-state matches are listed first, active matches are fetched with an independent `state=active` pass, and `counts.saved_matches` / @@ -2103,6 +2227,15 @@ Selector rules: 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`. +- `metadata.saved_state.ensure` is the preferred idempotent public facade for + that operation. It resolves `ConfigSave` or `ConfigCASSave` from the public + object/extension selector, copies only missing active parts, and never asks a + user to create a missing save layer in Configurator. It remains a plan by + default; its apply modes use the same explicit SQL gate. +- `metadata.saved_state.ensure.rollback` removes only rows inserted by the + opaque receipt returned from an ensure apply. It SHA-checks every row first, + requires `allow_sql_saved_state_rollback=true`, and never touches active + `Config` or `ConfigCAS`. - 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 @@ -2134,6 +2267,74 @@ Selector rules: 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. +- `configuration.activation.status` is the read-only activation boundary for + `saved_state -> active`. It checks both layers by default, or accepts + `layer=base_saved_state|extension_saved_state`, and delegates to the live SQL + saved-state comparison above. It never uses the code cache or vector index as + authority. `activation_required=true` means at least one `changed` or + `saved_only` object exists. If the configured scan limit is reached without a + detected difference, the result is `inconclusive` rather than a false + `up_to_date`. +- `configuration.activation.plan` uses that live status to build a read-only + handoff. When changes exist, it returns a `{method, params}` review call, a + manual Designer action (`Обновить конфигурацию базы данных`, F7), and a live + verification call whose expected status is `up_to_date`. The current adapter + does not start Designer or mutate the active configuration, even when + `ONEC_ADAPTER_ENABLE_EXTERNAL_1C` is enabled. A future execution method must + use a dedicated Designer bridge and separate explicit confirmation. +- `configuration.activation.request` creates an expiring adapter-local request + only when the live status is `activation_required`. The request fingerprint + includes the exact pending storage files and their saved/active SHA1 values; + storage coordinates and hashes are not returned as the public activation + status. Requests and lifecycle events are stored outside the infobase in the + local SQLite database selected by `ONEC_ADAPTER_STATE_DB`, in + `configuration_activation_requests` and + `configuration_activation_events`. The legacy JSON path selected by + `ONEC_CONFIGURATION_ACTIVATION_STATE_FILE` is imported once and then remains + read-only. +- `configuration.activation.execute` currently accepts only `mode=debug` and + requires `confirm_activation=true` plus the exact request id. It repeats the + live SQL read and rejects the request if it expired, the base differs, the + pending state disappeared, or any fingerprinted file changed. A successful + result is `debug_accepted`; Designer is not started and the active + configuration is not changed. Use + `configuration.activation.request.status` to inspect the adapter-local + request state. +- `configuration.activation.request.cancel` requires `confirm_cancel=true` and + records a lifecycle event for the exact request. Cancellation is idempotent + and has no 1C side effect. `configuration.activation.audit` returns a bounded + base-scoped list of request states and events without exposing the internal + per-file fingerprint evidence. +- `configuration.activation.capabilities` reports only boolean/configuration + readiness: runner kind, presence of a Designer path and infobase selector, + external-1C enable flag, and supported workflow gates. It never returns the + executable path, runner URL, infobase selector, users, passwords, or tokens. + The base layer reports documented `/UpdateDBCfg` as `debug_only`; extension + activation reports `manual_only` until a verified platform command is + implemented. +- `configuration.activation.bridge.probe` performs an explicit debug-only + readiness call. For a local runner it checks that the configured Designer + executable exists and exactly one infobase selector is present. For an HTTP + runner it calls `/configuration/activation/debug` using the existing runner + token configuration. Both paths return only booleans and runner kind: + Designer is not started, credential values are not read by the local probe, + and paths, URLs, selectors, users, and secret names are not returned. +- `configuration.activation.execute` accepts optional `bridge_debug=true`. + After all request, expiry, confirmation, live-SQL, and fingerprint checks + pass, the adapter sends only `base_id`, semantic layer, request id, and the + 64-hex fingerprint to the debug runner. The runner must return a matching + request/fingerprint plus a 64-hex receipt. A missing, mismatched, or + not-ready receipt blocks acceptance. A valid receipt records the + `bridge_debug_accepted` lifecycle event; it still does not start Designer or + change the active configuration. +- `configuration.activation.verify` closes the manual activation loop for an + exact request. It repeats the live SQL comparison with no cache. The result + is `not_activated` when the original fingerprint is still pending, + `changed_since_request` when pending files differ from the confirmed + fingerprint, and `verified_up_to_date` only when saved and active layers + align. SQL alignment does not prove that Designer performed activation—it + can also result from discarding or replacing saved changes—so the response + reports `activation_proven=false`. - `metadata.saved_state.changes.list` is the read-only pending-change overview across the base and extension saved-state layers. Use the semantic `layer=base_saved_state|extension_saved_state` filter when only one layer is @@ -2178,7 +2379,11 @@ Freshness statuses: - `cache_hit_verified`: cache candidate was rechecked against current SQL `payload_sha1` and `text_sha1`; - `cache_hit_stale`: cache candidate exists, but current SQL no longer matches; +- `cache_refreshed_from_sql`: a stale cache row was rebuilt from current SQL + and will be reranked before it can be returned; - `vector_candidate_unverified`: vector result is only a retrieval candidate. +- `dirty_with_live_fallback`: local coverage is incomplete, so the response + includes a direct live-SQL search for newly added code. RPC methods: @@ -2195,24 +2400,91 @@ RPC methods: } ``` -- `metadata.code_index.status`: reports cache/module/vector chunk counts; -- `metadata.code_index.search`: fast lexical search over cached BSL, verifying - candidates by default; +- `metadata.code_index.status`: reports cache/module/vector chunk counts, + pending outbox events, overlay tombstones, and the local snapshot token; +- `metadata.code_index.search`: strict lexical search over cached BSL. Strict + mode is the default: it overfetches, verifies against live SQL, refreshes and + discards stale rows, and switches to live SQL while the index is dirty; - `metadata.code_index.verify`: verifies one `module_ref` against live SQL; - `metadata.code_index.refresh_changed`: verifies search candidates and refreshes stale modules from SQL; -- `metadata.code_vector.search`: searches cached module/routine chunks with - local hashing embeddings or supplied `query_embedding`, then revalidates by - default. +- `metadata.code_index.sync_pending`: processes exact dirty/outbox targets after + saved-state writes; +- `metadata.code_index.poll_changes`: detects Configurator/external changes. + Saved tables are checked by default; use `include_active=true` for a less + frequent active-configuration scan; +- `metadata.code_vector.pending`: returns current BSL chunks that still need an + embedding for a requested model, with `chunk_id` and `text_sha1` + preconditions. Optional `chunk_kinds=["routine"]` and `max_text_chars` + support fast incremental passes without falsely marking skipped long chunks + as embedded; +- `metadata.code_vector.embedding.upsert`: stores an external embedding only + while those preconditions still match. Changed or removed chunks return + `conflict`; +- `metadata.code_vector.search`: hybrid lexical/vector search over cached + module/routine chunks. It overfetches, verifies live SQL, reranks after stale + refreshes, suppresses active rows shadowed by saved state, and returns + name-first `object_ref`/`object_selector` fields. + +If older global index rows have empty owners, run +`metadata.module_owner_cache.backfill` page by page. Search also performs a +cheap local owner lookup for top candidates. For base `Config`/`ConfigSave` +modules whose part name starts with a GUID, the adapter resolves that GUID +against current metadata, requires an exact identity match, and persists the +verified public owner. It never launches a configuration-wide owner scan on +the hot search path. + +For active extension `ConfigCAS` modules, the adapter follows the current +extension manifest from the exact CAS content hash to the owning descriptor, +decodes its public kind/name, and returns the extension name/GUID in both +`owner.extension` and `object_selector`. The manifest relation and descriptor +are cached locally only after this current-SQL resolution. Hashes are never +presented as object names. + +`strict=true` and `verify=true` are the safe defaults. `strict=false` is a +diagnostic/candidate mode and must not drive programming changes. + +The adapter keeps revision, dirty/outbox, tombstone, and vector data in the +local adapter SQLite database. It does not add project tables or settings to +the 1C SQL database. + +External embeddings are optional. The worker supports deterministic local +hashing for tests and any OpenAI-compatible embedding endpoint: + +```powershell +python scripts\embed_1c_code_vectors.py ` + --base-id upo_test ` + --embedding-provider openai-compatible ` + --embedding-model ` + --embedding-base-url http://: ` + --json + +python scripts\search_1c_code_vectors.py "где рассчитывается налог" ` + --base-id upo_test ` + --embedding-provider openai-compatible ` + --embedding-model ` + --embedding-base-url http://: ` + --embed-pending ` + --json +``` + +API keys are read only from the configured environment variable and are not +written to SQLite, responses, or project files. The embedding model label and +vector dimensions are stored so vectors from different models cannot be mixed. +For Matryoshka-capable OpenAI-compatible models the code worker includes the +requested dimension in the cache label (for example `@d384`), slices a longer +response deterministically, and normalizes the stored vector again. Operational modes: -- `fast`: cache plus SQL hash verification; +- `fast`: strict cache search plus SQL hash verification; - `live`: direct SQL search/read, slower but authoritative; - `background_refresh`: intended for long cache warming jobs. Never apply code changes from cache or vector output alone. Use the returned -`read_selector` after freshness is `cache_hit_verified` or read live SQL again. +`read_selector` after freshness is `cache_hit_verified`, then run the normal +live write preflight. A non-null snapshot token identifies a clean local index; +it is not a native 1C repository lock. ## Saved-State Form Search And Write Target Resolve @@ -2353,12 +2625,23 @@ Purpose: - build a routine chain for a concrete object and method name across base and extension modules; +- include modules of forms owned by the selected object, including extension + objects resolved from a public `ref`/`kind`/`name` without requiring an + extension GUID or CAS key; +- apply the same source policy as code reads: `state=working` prefers the saved + form-module counterpart with active fallback, `state=save` is saved-only, + `state=active` is active-only, and `state=both` can report both versions; +- mark every chain link with `activation_state=active|saved_state`, so a saved + routine is never mistaken for already applied runtime code; - return public read selectors for every found routine without exposing storage ids by default; - expose `chain[].extension_action` for each routine link. For base configuration links this is `operation_class=base_definition`; - for extension links, normalize known action evidence into `insert_before`, `insert_after`, `replace`, or `replace_with_control`; +- classify an unannotated routine of an object defined by the extension itself + as `extension_definition`; it is not an unresolved interception of a base + routine and therefore does not trigger the unknown-action write guard; - when a routine is found in an extension but the action metadata is not yet resolved, return `extension_action.status=unknown` and `operation_class=unknown_extension_action`. The agent must not treat this as @@ -2967,6 +3250,31 @@ Purpose: - return backup ids, source metadata, sha1 and byte counts without returning rollback payload hex. +## Saved-State Backup Retention + +RPC method: + +```json +{ + "method": "storage.saved_state.backups.prune", + "payload": { + "base_id": "", + "table": "ConfigCASSave", + "older_than_days": 30, + "keep_latest": 20, + "limit": 500, + "dry_run": true + } +} +``` + +The method only inspects adapter-local backup JSON files. It is base-scoped, +defaults to `dry_run=true`, preserves `keep_latest` matching backups, and never +writes the 1C database. Actual deletion additionally requires +`dry_run=false` and `confirm_delete=true`. Backups referenced by +`metadata.write.history` are never selected for deletion. If history lookup is +unavailable for a base, its matching backup files remain protected. + ## Kind Smoke Command: diff --git a/docs/1c-sql-format-spec.md b/docs/1c-sql-format-spec.md index 5dd13ab..57fd8ad 100644 --- a/docs/1c-sql-format-spec.md +++ b/docs/1c-sql-format-spec.md @@ -2,6 +2,11 @@ Date: 2026-06-20 +The maintained evidence log for saved-state encoding, extension overlays, +Configurator reload behaviour, and failed protocol paths is split by topic in +[`docs/1c-sql-protocol/`](1c-sql-protocol/README.md). This specification keeps +only universal format rules. + This document describes universal 1C SQL storage rules observed and verified so far. It must not contain knowledge about a particular infobase object such as `АвансовыйОтчет`, except as test evidence in a separate report. diff --git a/docs/1c-sql-protocol/README.md b/docs/1c-sql-protocol/README.md new file mode 100644 index 0000000..bfdb55f --- /dev/null +++ b/docs/1c-sql-protocol/README.md @@ -0,0 +1,46 @@ +# SQL protocol knowledge base for 1C configuration storage + +This directory is the durable, evidence-first record for the 1C SQL adapter. +It complements [the general format specification](../1c-sql-format-spec.md). +Do not put all observations into one chronological note: add a fact to the +document for its layer, object type, codec, operation, or experiment. + +## Non-negotiable runtime rule + +The adapter works **only through SQL**. It may decode and encode only what was +observed in the target database and proven by round-trip checks. It must never +invent a metadata object, physical path, BSL fragment, form element, join, +codec, or cryptographic value. Unknown or ambiguous work returns an explicit +unsupported/protocol-incomplete result. + +The Configurator remains a human-operated consumer of the saved-state overlay. +The adapter does not start it, automate it, or claim to change its in-memory +state. + +## Map + +- [Rules and evidence discipline](rules/evidence-and-safety.md) +- [Agent contract: do not invent](rules/agent-contract.md) +- [Configuration SQL layers](layers/configuration-storage.md) +- [Extension saved-state overlay](extensions/saved-state-overlay.md) +- [Common form and BSL module handling](objects/common-form-module.md) +- [Object component graph and child selectors](objects/component-graph.md) +- [Report object-module carrier](objects/report-object-module.md) +- [Payload envelope codec](codecs/payload-envelope.md) +- [`__configinfo` map and service atom](codecs/configinfo.md) +- [Configurator refresh behaviour](operations/configurator-cache.md) +- [Reproducible `upo_test/test2` experiment](experiments/upo-test-test2.md) +- [Known dead ends and prohibited shortcuts](research/known-dead-ends.md) +- [Template for the next experiment](templates/experiment-record.md) +- [Current adapter component map](implementation/adapter-components.md) + +## Maintenance rule + +After every material protocol investigation, record all three outcomes: + +1. proven direction and the exact evidence; +2. failed direction and why it failed; +3. remaining unknowns and the next safe experiment. + +Keep raw payloads and credentials out of git. Reference private learning +artifacts by opaque ID or SHA-1 only. diff --git a/docs/1c-sql-protocol/codecs/configinfo.md b/docs/1c-sql-protocol/codecs/configinfo.md new file mode 100644 index 0000000..79fd80a --- /dev/null +++ b/docs/1c-sql-protocol/codecs/configinfo.md @@ -0,0 +1,35 @@ +# `__configinfo`: file map and service atom + +## Proven map + +The decoded `E__configinfo` text contains logical file-name pairs: + +```text +".0", +``` + +The Base64 value is the SHA-1 of the complete raw `BinaryData` stream for that +logical file. Updating a module without replacing this map value produces the +Configurator error “Ошибка хеш-версии файла конфигурации”. + +The map rewrite is deterministic and losslessly round-trip proven for the +studied extension form module. + +## Service atom: current status + +The root block also contains three observed binary atoms: two 48-byte values +and a 32-byte value. The 32-byte value changed across manual saves, including +when the module bytes returned exactly to a previous SHA-1. A random mutation +of its variable part triggered a platform licensing/crypto error. + +Therefore: + +- preserve all service atoms byte-for-byte during the proven incremental + protocol; +- do not synthesise, randomise, zero, or “recalculate” them; +- do not claim their reverse codec is known; +- record new observations in an experiment file before changing this rule. + +The `test2` map-update experiment activated successfully while preserving the +existing service atom. This is activation evidence for preservation, not for +generation. diff --git a/docs/1c-sql-protocol/codecs/payload-envelope.md b/docs/1c-sql-protocol/codecs/payload-envelope.md new file mode 100644 index 0000000..1d9906d --- /dev/null +++ b/docs/1c-sql-protocol/codecs/payload-envelope.md @@ -0,0 +1,27 @@ +# Payload envelope codec + +## Observed codec pipeline + +For the studied configuration payloads: + +```text +SQL BinaryData + → raw-deflate (or detected alternative envelope) + → UTF-8 text, sometimes BOM + → brace-based 1C serialized value tree / container text +``` + +The decoder must retain envelope type, original encoding, BOM, line endings, +and all unmodified text. An encoder is valid only if an unchanged +decode→encode cycle returns byte-identical payload bytes. + +## Lossless transform discipline + +1. Decode source bytes. +2. Identify the exact BSL or value-tree slice with a proven extractor. +3. Replace only that slice. +4. Encode with the original codec metadata. +5. Calculate SHA-1 from the final raw stored bytes, never from decoded text. + +Do not canonicalize braces, whitespace, strings, BOM, compression level, or +base64 blocks without an independently proven canonical writer. diff --git a/docs/1c-sql-protocol/experiments/upo-test-test2.md b/docs/1c-sql-protocol/experiments/upo-test-test2.md new file mode 100644 index 0000000..97b7f8a --- /dev/null +++ b/docs/1c-sql-protocol/experiments/upo-test-test2.md @@ -0,0 +1,72 @@ +# `upo_test` / `test2` saved-state experiment + +## Scope + +- Test base: `upo_test` +- Extension: `test2` +- Extension GUID: `fb26cf42-7609-11f1-828f-005056b0d483` +- Common form: `t_Форма` +- Form GUID: `77494708-43ea-4956-ac3c-199cfb035ad2` +- Independently manually edited form: `tt_Форма3` +- Form GUID: `99590008-addf-49fa-9ada-24962756d0cf` + +These are experiment identifiers, not a universal hardcoded route. + +## Follow-up cross-extension confirmation (2026-08-13) + +The same logical-file protocol was exercised on a different extension and +object class: + +- Extension: `фс_Отчеты` +- Extension GUID: `9b11f844-3d08-11f1-8287-005056b0d483` +- Object: `Report._ПоступлениеТовара` +- Object GUID: `c428f629-785a-4141-b038-f2192bb4580d` +- Guarded change: `//Пример - 3` → `//Тест - 3` + +The active root was a hash-keyed `ConfigCAS` record, but decoding it exposed +the logical `O` / `O.0` map and the SHA-1 of each active payload. The adapter +created exactly `E__configinfo`, `E__O`, `E__O.0`, executed a public +`code.write` replacement and reread it. A controlled `apply_and_rollback` +passed before `apply_and_verify`. A human then confirmed `//Тест - 3` in the +Configurator extension editor. + +This proves the canonical object-module overlay route for this observed +extension-root family. The implementation must retain the suffix of the exact +resolved BSL stream: a separately diagnosed production route for +`Report.УОП_ПечатьЦенниковАссортимента` resolves its manager module as `.2`, +not `.0`. This does not authorize a guessed suffix: the selected `O.S` entry +and its SHA-1 must be present in the decoded root map. Forms or an unknown +root that cannot be decoded into that exact logical map remain unproven. + +## Outcomes + +1. Writing a `.0` module alone made a comment visible after a reload but + failed activation with a hash-version error. +2. Pairing the module write with the matching `__configinfo` map update made + the comment activate into `ConfigCAS`; Configurator cleared + `ConfigCASSave` after successful application. +3. Building a complete three-file initial overlay while Configurator was + closed, then opening and applying it, succeeded for `change-10`. +4. A manual save in `tt_Форма3` produced a three-row pending overlay for that + form plus `__configinfo`. Incrementally adding `t_Форма` descriptor/module + while preserving the existing rows and map entries kept both edits. +5. A pending `__configinfo` map can point at a newer active module than an + earlier inspection of `ConfigCAS`; selecting the source through the pending + map avoids duplicate BSL comments. + +## Failed paths retained as evidence + +- Raw module-only write: invalid collection hash. +- Random service-atom bytes: licensing/crypto error. +- Treating the active module as authoritative while a pending map exists: + duplicate comment insertion. +- Assuming UI text means SQL save: the editor buffer can differ from + `ConfigCASSave`; verify SQL immediately after saving. + +## Verification after every apply + +1. `ConfigCASSave` for the extension becomes empty. +2. Extension root key in `_ExtensionsInfo`/`ConfigCAS` changes to the new + `__configinfo` SHA-1. +3. Active `ConfigCAS` contains the requested BSL text. +4. The active map points to the SHA-1 of the active module bytes. diff --git a/docs/1c-sql-protocol/extensions/saved-state-overlay.md b/docs/1c-sql-protocol/extensions/saved-state-overlay.md new file mode 100644 index 0000000..7f8eaca --- /dev/null +++ b/docs/1c-sql-protocol/extensions/saved-state-overlay.md @@ -0,0 +1,128 @@ +# Extension saved-state overlay (`ConfigCASSave`) + +> **Deployment and integration-test target:** `adapter-1c-mcp` runs on +> `docker.cin.su`. Use that host for deployment and live adapter checks; +> `test-docker` is staging only and requires an explicit request. + +## Canonical object-module overlay: activation evidence + +For extension GUID `E` and object GUID `O`, the overlay uses: + +```text +E__configinfo +E__O +E__O.S +``` + +This route is now human-confirmed in two independently selected extension +object-module cases on `upo_test`: + +| Extension | Object | Public test | Human confirmation | +|---|---|---|---| +| `test2` | Common form/module fixture | prior `change-10` experiment | Configurator applied overlay into `ConfigCAS` | +| `фс_Отчеты` | `Report._ПоступлениеТовара` object module | `//Пример - 3` → `//Тест - 3` | Configurator displayed `//Тест - 3` in the extension editor | + +The descriptor and module are separate objects. A pending extension may contain +only rows changed manually by the user; the root `__configinfo` map can still +refer to unchanged active parts. An adapter must preserve those rows and all +their map entries. + +For the separately observed hash-keyed overlay, the equivalent boundary is the +live extension `root_cas_key`: an automatic first-write copy must include that +root manifest together with the selected object parts. A leaf module key alone +is not a complete working-copy selection boundary. + +## Hash-keyed first-write status + +In `upo_test` / `фс_Отчеты`, copying the complete evidenced hash-key group +from `ConfigCAS` to `ConfigCASSave` under unchanged hash names produced a +byte-for-byte SQL readback, but Configurator continued to read the active +`ConfigCAS` module. Therefore a same-name hash copy is **not** a working-copy +protocol. + +The active extension root has since been decoded as the equivalent logical +file map: it contains `"[.suffix]",Base64(SHA-1(payload))` +pairs, including the target descriptor and the selected BSL module. For the evidenced +object-module family the initial overlay maps exactly three active files to: + +```text +root manifest -> E__configinfo +object descriptor -> E__O +object `.S` module -> E__O.S, where `.S` is the suffix of the exact resolved +BSL stream (for example `.0` or `.2`) +``` + +The adapter must reject any incomplete route and must never fall back to +unchanged hash names. The map and all service atoms are copied verbatim on the +first overlay; subsequent writes update only the proven file-SHA reference. + +### Exact prepare/write algorithm + +1. Resolve the extension, public object `O`, active descriptor and active `.S` + BSL stream from the extension manifest. +2. Decode the selected active root. Require exactly one map entry for `O` and + exactly one for the selected `O.S`; require their SHA-1 values to equal the selected + active descriptor/module payloads. +3. If the overlay is absent, atomically copy only those three sources as + `E__configinfo`, `E__O`, and `E__O.S`. Do not copy the whole extension. +4. If it exists, preserve pending rows and maps; never overwrite another + change. Prepare may add only missing object parts. +5. Replace BSL only after the exact old fragment, source SHA-1, extension, + public object and module stream agree. For an object module, update the one + `O.S` SHA-1 value in `E__configinfo` in the same transaction and preserve + service atoms byte-for-byte. +6. Re-read the BSL and both changed rows. `apply_and_rollback` must restore + the module and remove adapter-created first-overlay rows. + +`code.write` callers provide only public selectors and the replacement. The +adapter owns storage mapping, preparation, paired update and rollback. + +## Rejected approaches (retain as regression hazards) + +- Copying `ConfigCAS` hash rows to `ConfigCASSave` under the same names. It + produced correct SQL readback but Configurator ignored it for `фс_Отчеты`. +- Writing only the BSL module payload. The map continues to point to the old + SHA-1 and Configurator reports a collection/hash-version error. +- Generating `__configinfo`, its service atoms, or logical file mappings from + names alone. The adapter must first decode the actual selected root map. +- Replacing the entire compressed module container. Only the declared BSL + prefix codec is allowed; opaque stream tail bytes must remain unchanged. +- Resolving an already selected object-module path through generic metadata + path traversal. Its module suffix is a BSL container, so this can fail before the + saved-state writer sees the exact module route. + +## Proven incremental write protocol + +For a form module change: + +1. Read the current `E__configinfo` from `ConfigCASSave` if it exists; + otherwise derive a complete initial overlay from active `ConfigCAS`. +2. Resolve the form descriptor and module hashes from that map. +3. Patch the module with a unique BSL anchor and lossless payload codec. +4. Replace exactly the Base64(SHA-1(raw module bytes)) value paired with + logical name `O.0` in `E__configinfo`. +5. Preserve every unrelated map entry and service atom byte-for-byte. +6. In one transaction insert missing `E__O`/`E__O.0` rows and update + `E__configinfo`, with compare-and-set SHA-1 preconditions. + +The adapter core exposes a pure `build_extension_saved_state_pair_plan` helper +for steps 3–5. It refuses a plan when the ConfigInfo map does not reference the +current saved stream, so a later transaction cannot silently overwrite a +divergent human overlay. + +For an already existing single-part pending stream, the paired writer locks the +module and `__configinfo`, verifies both preconditions, writes both payloads +and their `DataSize` values in one transaction, keeps independent rollback +evidence, and verifies both rows afterwards. `__configinfo` itself is never a +primary editable stream. Missing overlay rows still use the separate prepare +route before this writer may update them. + +This protocol was activation-proven in the `test2` experiment. It is not yet +a universal proof for every 1C platform version or every extension object +class; new classes require their own evidence record. + +## Important overlay behaviour + +An external SQL overlay write does not set the Configurator’s in-memory dirty +flag. Its visibility therefore depends on the current session. See +[Configurator refresh behaviour](../operations/configurator-cache.md). diff --git a/docs/1c-sql-protocol/implementation/adapter-components.md b/docs/1c-sql-protocol/implementation/adapter-components.md new file mode 100644 index 0000000..35adf2d --- /dev/null +++ b/docs/1c-sql-protocol/implementation/adapter-components.md @@ -0,0 +1,24 @@ +# Current adapter component map + +This map says where to extend the project without losing protocol boundaries. +It is not a substitute for reading the linked source before editing it. + +| Component | Location | Responsibility | Evidence boundary | +|---|---|---|---| +| RPC/service orchestration | `plugins/1c/connector/adapter_1c_server.py` | Base resolution, live SQL calls, saved-state plans/apply, verification, backups | Must keep active layers read-only | +| Payload codec | `plugins/1c/parser/payload.py` | Envelope detection, decompression, lossless encode metadata | Require byte-identical unchanged round trip | +| Brace parser | `plugins/1c/parser/*` | Decode 1C serialized brace/value trees | A parse tree is not semantic proof by itself | +| Extension routes | `plugins/1c/parser/extensions.py` and adapter extension resolvers | `_ExtensionsInfo` and `ConfigCAS` manifest routes | Never infer part role from suffix alone | +| DBNames mapping | `plugins/1c/parser/dbnames.py` | Metadata-to-physical SQL role evidence | Names/roles must come from live DBNames evidence | +| Storage access | adapter storage helpers | Single/multi-part row reads, hashes, paired extension map updates, compare-and-set and backups | Writes only to allowed saved-state tables | +| Policy | `plugins/1c/connector/policies/` | Runtime boundary and base-access rules | Policy must match tested capability, not aspiration | +| Regression tests | `tests/1c/` and `scripts/smoke_1c_*` | Preserve routes and observed protocol rules | Add fixture/test before promoting a decoder rule | + +## Required update order for a new object type + +1. Add an experiment record with before/after SQL evidence. +2. Add/extend a decoder fixture and test. +3. Add the narrow codec or route resolver. +4. Add a writer only after lossless round-trip, paired-index handling, rollback, + and human activation verification. +5. Update the relevant topic document and this map if ownership changed. diff --git a/docs/1c-sql-protocol/layers/configuration-storage.md b/docs/1c-sql-protocol/layers/configuration-storage.md new file mode 100644 index 0000000..3e28a3b --- /dev/null +++ b/docs/1c-sql-protocol/layers/configuration-storage.md @@ -0,0 +1,39 @@ +# Configuration storage layers + +The following SQL tables have the same observed storage-row shape: + +`FileName`, `Creation`, `Modified`, `Attributes`, `DataSize`, `BinaryData`, +`PartNo`. + +| Layer | Role | Direct adapter writes | +|---|---|---| +| `Config` | Active base configuration | Forbidden | +| `ConfigSave` | Pending base configuration changes | Controlled `upo_test` only | +| `ConfigCAS` | Active extension content-addressed store | Forbidden | +| `ConfigCASSave` | Pending extension configuration overlay | Controlled `upo_test` only | + +`PartNo` must be read and preserved. The currently proven writer handles a +single-part stream only; a multi-part stream is unsupported until a +table-aware round-trip codec exists. + +## Addressing rules + +Base `Config` objects are commonly addressed by metadata GUID. Extension active +objects are reached through `_ExtensionsInfo` → extension root in `ConfigCAS` +→ root manifest → object part SHA-1 key. Do not infer a semantic role from a +suffix such as `.0`; inspect the payload and route evidence. + +For normal content-addressed rows, `ConfigCAS.FileName` was observed to equal +SHA-1 of the stored bytes. Always compute and compare the hash rather than +trusting the name: an activation experiment left a legacy alias whose name was +an old key while its bytes had the new SHA-1. + +## Write sequence + +1. Resolve public object/form/module against live SQL. +2. Read active bytes and any existing saved-state rows. +3. Prepare only missing saved rows; never overwrite a user’s existing rows. +4. Encode the changed stream losslessly. +5. Update every proved companion index in the same SQL transaction. +6. Read back and validate hashes and semantic exact-match count. +7. Tell the human which Configurator scope to reload. diff --git a/docs/1c-sql-protocol/objects/common-form-module.md b/docs/1c-sql-protocol/objects/common-form-module.md new file mode 100644 index 0000000..62e9a92 --- /dev/null +++ b/docs/1c-sql-protocol/objects/common-form-module.md @@ -0,0 +1,28 @@ +# Common form modules + +## Proven form facts + +The `test2` common forms `t_Форма` and `tt_Форма3` are distinct objects with +distinct GUIDs and descriptors. A label must be decoded from the descriptor; +do not shorten or normalize it by guesswork (`tt_Форма3` is not `t_Форма3`). + +A form `.0` payload can contain more than a simple standalone BSL string. The +adapter must use the proven container extractor and preserve all non-BSL +segments, including form settings and command metadata. + +## Module patch rules + +- Obtain the BSL region from the decoded container, not from a global text + search over compressed bytes. +- Count the requested anchor in the relevant BSL region. +- Replace a unique exact fragment once; report ambiguity otherwise. +- Re-encode using the source payload’s detected codec and line-ending style. +- Read back, re-extract BSL, and verify the target occurrence count. + +## Pending-state resolution + +When `ConfigCASSave` contains a `__configinfo` map, that map is authoritative +for the pending overlay even if the corresponding module row is absent. The +active `ConfigCAS` source must be selected through the saved map before a new +delta row is created. Reading only active content can incorrectly conclude +that a pending comment is absent and cause a duplicate insertion. diff --git a/docs/1c-sql-protocol/objects/component-graph.md b/docs/1c-sql-protocol/objects/component-graph.md new file mode 100644 index 0000000..eb80892 --- /dev/null +++ b/docs/1c-sql-protocol/objects/component-graph.md @@ -0,0 +1,55 @@ +# Граф составляющих объекта конфигурации + +Объект конфигурации — это корень, а не минимальная единица работы. Его +составляющие: модуль объекта, формы и их модули, макеты, схема компоновки +данных (СКД), а после декодирования СКД — наборы данных, запросы, поля, +ресурсы и варианты. Для записи нужен адрес именно составляющей и доказанный +физический носитель этой составляющей. + +## Публичная инвентаризация + +`metadata.object.components` — read-only фасад над уже доказанными SQL +декодерами. Он принимает обычный селектор владельца (`ref` либо `kind` и +`name`, при необходимости `extension`) и возвращает граф: + +- корень `metadata_object`; +- только реально найденные `module`, `form`, `template` и `scd`; +- публичные `path` и `read_selector` каждого потомка; +- `unresolved` для областей, которые не были подтверждены. + +Метод не создаёт потомок потому, что он обычно есть у такого типа объекта. +Например, пустой ответ `metadata.object.modules` у общей формы означает +«модуль этим маршрутом не найден», а не повод назвать форму модулем. + +Пример запроса: + +```json +{ + "method": "metadata.object.components", + "payload": { + "base_id": "upo_test", + "extension": "test2", + "ref": "Report.tt_Отчет", + "include_storage": false + } +} +``` + +`read_selector` можно передать следующему read-only методу. Это не является +разрешением на запись: запись выбирает компонент по его `path`, повторно +подтверждает конкретный carrier/stream и разрешается только для семейства +контейнеров с доказанным byte-preserving кодеком. + +## Иерархия и кодеки + +Логический BSL один и тот же независимо от владельца, но его физический +носитель может различаться. Поэтому не нужны отдельные эвристики «для формы» +и «для отчёта»: нужен реестр семейств carrier-кодеков. Один кодек применяется +к нескольким типам объектов лишь после доказательства одинаковых границ, +непрозрачных атомов и round-trip проверки. + +Текущий пример: модуль общего формы в `upo_test/test2` имеет доказанный +write-кодек saved-state; обнаруженный поток модуля отчёта — только +read-only structural codec. Второй компонент может быть найден и прочитан, +но не получит разрешение на SQL-запись, пока его служебный хвост не будет +декодирован. diff --git a/docs/1c-sql-protocol/objects/report-object-module.md b/docs/1c-sql-protocol/objects/report-object-module.md new file mode 100644 index 0000000..752dc33 --- /dev/null +++ b/docs/1c-sql-protocol/objects/report-object-module.md @@ -0,0 +1,115 @@ +# Модуль объекта отчёта в расширении + +Статус: чтение, точное разрешение владельца и контролируемая запись короткого +фрагмента поддержаны для доказанного hash-keyed saved-state маршрута. + +Наблюдение в `upo_test`, расширение `test2`, отчёт `tt_Отчет`: сохранённый файл +`__.0` является `raw_deflate` контейнером из пяти +потоков. BSL-модуль расположен в потоке `4`. + +Поток начинается читаемым UTF-8-комментарием, но последующий текст содержит +нулевые байты и смешанное представление символов. Общий потоковый декодер +позволяет найти комментарий, однако его обратное кодирование меняет байты +неизменённого хвоста BSL. Экспериментальная запись показала это в Конфигураторе +и была немедленно восстановлена из парной резервной копии. + +Правило: наличие читаемого BSL-фрагмента не доказывает возможность записи. +Для потока с `NUL` адаптер возвращает +`mixed_encoding_module_stream_unsupported` и не создаёт SQL-изменений. +Это не означает, что для каждого отчёта нужен свой кодер: один доказанный +кодек может обслуживать все модули с одинаковым физическим носителем. + +Два ручных образца определили безопасную границу записи: редактируется только +объявленный UTF-8-префикс, а непрозрачный хвост и остальные потоки сохраняются +побайтно. Для hash-keyed overlay рабочий слой создаётся доказанным копированием +подтверждённых ключей `ConfigCAS → ConfigCASSave`; `__configinfo` для него не +создаётся и не предполагается. + +Текущая реализация `parser.cas_payload.stream_blocks_with_data` ищет похожие +заголовки регулярным выражением по всему распакованному буферу. В потоке +отчёта такие последовательности встречаются и внутри данных, поэтому это +эвристика для чтения, а не структурный декодер. Нельзя использовать её индекс +потока как основание для обратной записи. + +Структурный read-only декодер `decode_declared_utf8_bsl_prefix` подтверждён на +этом образце: пять последовательных блоков; пятый имеет `declared_1 = 68` и +`declared_2 = 512`. Первые 68 байт — UTF-8 BOM и точный BSL-текст, оставшиеся +444 байта — непрозрачный служебный хвост. Декодер вернул только: +`// protocol-report-baseline-1` и `// protocol-report-manual-change-4`. + +## Пара ручных образцов `2 → 3` + +Образцы `samples/manual-change-2.json` и `samples/manual-change-3.json` +содержат raw-deflate байты, сохранённые человеком в Конфигураторе. В +распакованном контейнере длиной 1283 байта замена цифры `2` на `3` изменила +BSL ровно в смещении `838` (`0x32 → 0x33`). Одновременно платформа изменила +шесть служебных диапазонов: `110..113`, `230..252`, `437..464`, `590..593`, +`598..601`, `716..719`. Трёхбайтовое значение повторяется в нескольких +местах, а два диапазона содержат связанные Base64-представления. + +Это доказывает, что нельзя перепаковывать поток общим writer'ом. Отдельный +fixed-width кодек меняет только первые `declared_1` байт: короткий текст +дополняется пробелами внутри этого поля, хвост и размер члена не меняются. +Рост префикса или структурная правка процедуры явно отклоняются. + +## Полный объявленный поток: переменная длина + +Нельзя переносить ограничение fixed-width с описанного выше носителя на все +объектные BSL-модули. На рабочем маршруте `upo / фс_Отчеты / +Report.УОП_ПечатьЦенниковАссортимента / .2 / stream:4` подтверждён другой +контейнер: у выбранного BSL-потока `declared_1 == declared_2 == 36101` и +`opaque_tail_bytes == 0`. Это полный UTF-8 поток, а не префикс перед +непрозрачными данными. + +Для такого носителя адаптер использует обычный структурный stream writer: +он меняет текст, пересобирает оба объявленных размера в заголовке и сдвигает +только последующие байты контейнера. Локальная обратная проверка целевой +замены `НоваяСтрока.Выбран = Истина;` на более длинный фрагмент дала размер +потока `36101 → 36198`, новый заголовок `36198/36198`, одно новое вхождение и +нулевое старое. Все байты до заголовка выбранного потока сохранились. + +Правило выбора кодека: fixed-width применяется **только** если доказан +ненулевой непрозрачный хвост; если `declared_1 == declared_2` и хвоста нет, +безопасна контролируемая замена переменной длины через структурный writer. +Неизвестный или частично декодированный контейнер остаётся заблокированным, +а не переводится в переменную длину по предположению. + +## Правило публичного маршрута + +Если объектный модуль состоит только из комментариев, это всё равно BSL-модуль: +у него нет маркеров `Процедура`/`Функция`, но его наличие подтверждает +структурный UTF-8-префикс в потоке. Адаптер обязан вернуть владельца и точный +селектор чтения, не заставляя клиента искать поток. При записи он обязан +использовать только fixed-width кодек, а не общий stream writer, который +перезаписывает непрозрачный хвост. Парное обновление `__configinfo` допустимо +только в отдельно подтверждённом каноническом layout. + +## Повтор `code.write` после успешной записи + +Повтор одного и того же публичного `code.write` не является новой операцией. +До автоматической подготовки `ConfigCASSave` адаптер читает указанную +процедуру в `effective_working`. Если старого фрагмента уже нет, а точный +новый фрагмент присутствует ровно один раз в этой же процедуре, результат — +`status: already_applied`, `applied: false`. В этом случае запрещены и +подготовка saved-state, и новая SQL-запись. + +Это правило предотвращает опасный путь: повторный запрос нельзя начинать с +активного `ConfigCAS`, потому что его копирование способно заново построить +рабочую копию из доизменённого источника и скрыть факт уже выполненной +операции. Если оба фрагмента отсутствуют, новый фрагмент встречается +несколько раз либо процедура не подтверждена, идемпотентность не +предполагается: применяется обычная безопасная ошибка `not_found`/ +`ambiguous` или диагностика маршрута. + +## Цепочка версий `2 → 3 → 4` + +Третий live-SQL образец подтвердил повторяемую часть протокола. 20-байтовое +Base64-поле в каждой новой версии равно SHA-1 сырого файла предыдущей версии: +запись `3` хранит SHA-1 записи `2`, а запись `4` — SHA-1 записи `3`. Это +доказанная ссылка версии, а не случайный текст. Его контрольный SHA-1: +`fc84f0a9ef17034f8d82f44c5f9b07064864b524`. + +Рядом расположен 16-байтовый токен, который меняется при каждом сохранении и +дублируется фрагментами в трёх служебных местах. Алгоритм его создания не +декодирован: адаптер его не генерирует и не изменяет. Его нельзя считать +основанием для создания или изменения `__configinfo` в hash-keyed overlay. diff --git a/docs/1c-sql-protocol/objects/report-object-module/samples/manual-change-2.json b/docs/1c-sql-protocol/objects/report-object-module/samples/manual-change-2.json new file mode 100644 index 0000000..a76e805 --- /dev/null +++ b/docs/1c-sql-protocol/objects/report-object-module/samples/manual-change-2.json @@ -0,0 +1,12 @@ +{ + "schema": "onec_report_module_stream_sample.v1", + "origin": "Configurator manual save in upo_test/test2/tt_Отчет; no adapter write", + "logical_change": "// protocol-report-manual-change-2", + "physical_file_name": "__.0", + "compression": "raw_deflate", + "raw_sha1": "89827a3c7fa07ae50a268d20a7cee34195d1d7a7", + "raw_bytes": 618, + "inflated_sha1": "6385a80972841eb8a97df43596ed15dd16966495", + "inflated_bytes": 1283, + "raw_base64": "7VLJbhNBEG2iPlnyBXHgBKMRByDTcc/S0zNCBtnEie2MvI2XwQJFPat3J/ZkV4TCgV/gCge+ggMfwE9w4MaBTzDtbLIFQYgzT+rq6q5+9apVNZvNXoMVAG6BCyQT+ByyIVw7NLyAkExs8idv+Zpx2kdO+7Zy4V/RFCxcOws0of/q4cvcCnj/+OzRfJ+jC0YgBOMlVRwKl+wl+o9Pn09USZawJIoSPrUyL5qqOQ39SafeLxxF5YlbpLvtDc9JJhMZfdUYD+L8gdHqWa2CWqNuX8aHekQ766GjlUqpIKodbzQcJ5M+lZKJE1MSZeK6MnMZ0onMkKYGKnJVipFHNdPHpoI9F4tSs1jp9OS6TTZ9u0GmTlVtDtrtmm9YuTTPYRjEoExHGtVVpOnYQKYcYkRNrCvU1TSdeaJU2S8PGo2jdaVXtVvTeF1TUsNi02rtVKp/mWONV7JVydojpRo6qtVNPW8acbe+29JLBWNvnoXyqjXKufwj/Dcm0RHzVA/JpumFLlYJ8xVROp760f6w5tml4n6l1GnQYSNbdFyl0MN/yHHd56vxUJbG46q/i31+AGwQgwkIAANDflrst2bc2G9dkgMiK4y5SP6H6Yq53iG3S2raTWqplLAzGcdjbzxAk2BnPImRy6bBoDsK5uq/CQ/ZaI8NkNdhoyhACtY9X2WUiFLZjywrzvhReJDP94ZRJqjn28pqPlcz0qdwjZcUg21wD2ZgGj6FCrcYlqHA/SfQgoIgfN2y30l3efzZ5R09f4dhlvuLvC1ILnkGvMNtGd7mUQJz8P4vTMLPBJrwe/YNl/jwxT4TBPAf5/gJ" +} diff --git a/docs/1c-sql-protocol/objects/report-object-module/samples/manual-change-3.json b/docs/1c-sql-protocol/objects/report-object-module/samples/manual-change-3.json new file mode 100644 index 0000000..ea6d691 --- /dev/null +++ b/docs/1c-sql-protocol/objects/report-object-module/samples/manual-change-3.json @@ -0,0 +1,12 @@ +{ + "schema": "onec_report_module_stream_sample.v1", + "origin": "Configurator manual save in upo_test/test2/tt_Отчет; no adapter write", + "logical_change": "// protocol-report-manual-change-3", + "physical_file_name": "__.0", + "compression": "raw_deflate", + "raw_sha1": "357c39a55e8f66c3efd8f7aac968a4db5d5eb5fb", + "raw_bytes": 620, + "inflated_sha1": "eb72cadfe12f68969cd278901a6f83e627ee1a53", + "inflated_bytes": 1283, + "raw_base64": "7VLLbhMxFDWVV5GyQSxYwWjEApRxa8+MxzOqAkrV0HQ6ah6TVyMQ8jyTNMmkybSlVBWCBb8AWxZ8BQs+gBV/wIIdCz4huE8lgiLEmiP5+trX555r3TubzV6CJQBugHNkM/gMxJSuHBadQ8pmNsSTN2LNBO2DoH1bOvcvaSqWrpw5mrT79P6T4hKofHn34HQ/RQ+MQASSBVUcSRfsBfqPj5+ONYUoWJFlBZ84hZ2mZk2jYNKt724exeWJZ7O9zmO/nc1mCkbOTAZp6dBs9Z3WplZj3i6h+lGQs8l2vZ0YNNnTUr5hr0/zJ0o2c2wpMqGeR7jHkUEJR7oWasjTGEY+060AWyr2PSwrTbvS7ZO6SzcCt0Gn7arWHHQ6tcB0inmRwzSpybiBdGZoSDewiSwSYcQsbKjM03WD+7JSOSgPGo2jdbVfdVvTdF1XV4Z202mNK9W/zLEsKunt2EallIvD/caWneB4bLJ+tdOuEcMXWZioWmeCKz4ifmNRA3Ff8xGxLD/ysEZ5oMrKi2kQHwxrvrttH1S2uw02bKzZbU/d7OM/5Ljq8+V4qAvjcdnf+T7fAy5IwQSEgIOhOM33Wzev7behkJASlXMPkX+YrlToPRd2QU2/Tm1lRRpPkjTxkwGahONkkiKPT8NBbxSeqv8mPOSjfT5AfpeP4hBp2PADjTMqK+Ugdpy0EMTRYanUH8aFsF7qqLlSsWbmT+CyKCkFz8AdWIB5+BCqwmJYhpLwV6EDJUn6uuW+VW6L+KOLO3b2DsM14c/ztiC94JnwlrBleFNEKSzCu78wqThTaMHva6+FxPvP7itJAv9xhp8=" +} diff --git a/docs/1c-sql-protocol/operations/configurator-cache.md b/docs/1c-sql-protocol/operations/configurator-cache.md new file mode 100644 index 0000000..7008044 --- /dev/null +++ b/docs/1c-sql-protocol/operations/configurator-cache.md @@ -0,0 +1,35 @@ +# Configurator refresh behaviour + +## Observed session model + +`ConfigCASSave` is an SQL overlay, but writing it outside Configurator does not +set Configurator’s in-memory changed/dirty state. + +Observed consequences: + +| State before adapter write | Minimal human action after write | +|---|---| +| Object already existed in saved-state | Close and reopen that object | +| Adapter created the first pending object for an extension | Close and reopen the extension | +| Adapter created the first pending object for base configuration | Close and reopen the configuration | + +If the user manually edits and saves any object in an extension, Configurator +marks the extension changed; reopening another object can then load its +`ConfigCASSave` overlay. + +## Required adapter response + +Write results should return machine-readable guidance: + +```json +{ + "configurator_refresh": { + "required": true, + "scope": "object|extension|configuration", + "action": "close_reopen_object|close_reopen_extension|close_reopen_configuration" + } +} +``` + +This guidance does not claim that the adapter controls Configurator; it merely +reports the minimum observed reload boundary. diff --git a/docs/1c-sql-protocol/research/known-dead-ends.md b/docs/1c-sql-protocol/research/known-dead-ends.md new file mode 100644 index 0000000..1dd5597 --- /dev/null +++ b/docs/1c-sql-protocol/research/known-dead-ends.md @@ -0,0 +1,27 @@ +# Known dead ends and prohibited shortcuts + +## Do not repeat + +- Do not write only a module `.0` row: `__configinfo` then references stale + bytes and activation fails. +- Do not use random, zeroed, copied-from-unrelated, or guessed HashVersion + service atoms. A controlled random test produced a licensing/crypto error. +- Do not write `Config` or `ConfigCAS` directly, even in tests. +- Do not use a suffix such as `.0` as proof of “object module”. +- Do not infer an object name from a GUI tree label, table suffix, or an + approximate Russian name. +- Do not overwrite all saved-state rows when adding a delta: preserve user + work in other objects and every map entry. +- Do not treat an open Configurator screen as SQL evidence. + +## Open questions + +- The generation algorithm and ownership of the 32-byte `__configinfo` + service atom are unknown. Preservation is proven for the studied flow; + generation is not. +- Multi-part storage streams need a dedicated row-layout codec. +- The extension protocol must be reproduced on another object class and a + second platform build before being declared generally supported. +- Configurator’s in-memory dirty-state implementation is inferred from + behaviour, not decoded from platform source. Only the reload guidance is + operationally relied upon. diff --git a/docs/1c-sql-protocol/rules/agent-contract.md b/docs/1c-sql-protocol/rules/agent-contract.md new file mode 100644 index 0000000..23978b6 --- /dev/null +++ b/docs/1c-sql-protocol/rules/agent-contract.md @@ -0,0 +1,51 @@ +# Contract for an agent using the 1C SQL adapter + +The agent is a consumer of evidence returned by the adapter. It must never +turn a plausible interpretation into a fact. + +## Fundamental adapter rule + +The adapter is a **SQL codec**, not an expert system for 1C. It reads and +writes only according to the versioned configuration-storage specification +that has been decoded from live SQL and recorded in this knowledge base. +It may expose a semantic name only when that mapping is proven by the decoder. +For an unknown carrier, field, child object, byte range, checksum, or service +atom, the only valid result is `unsupported`, `partial`, or `ambiguous` with +the observed evidence. It must not synthesize a structure, BSL, or value to +make an operation appear complete. + +## Required behaviour + +- Start from a public 1C name/ref supplied by the user. +- Ask the adapter to resolve the live route; internal GUIDs, SQL numbers and + file names remain adapter implementation details. +- Use public `code.read`, `code.search`, and `code.write` for normal BSL work. + Never pass or request `ConfigCAS`, `ConfigCASSave`, a payload hash, a + canonical saved filename, or a stream index. Those are diagnostic evidence, + not an agent-facing selector contract. +- Read the target bytes before proposing any edit. +- Quote the exact proven fragment, its count, and the selected layer. +- For a write, require a plan/preflight and retain the returned rollback and + refresh guidance. +- State `unknown`, `ambiguous`, or `protocol_incomplete` when evidence is + absent. Ask for a larger fragment or a human Configurator action instead of + guessing. + +## Forbidden behaviour + +- Invent BSL procedures, form controls, field paths, joins, storage tables, + module streams, extension ownership, or `__configinfo` atoms. +- Claim that a SQL saved-state edit is active before active-layer verification. +- Claim a repository lock merely because an adapter request was recorded. +- Tell the user that the Configurator UI has refreshed unless the required + close/reopen boundary was completed by the human. +- Write directly to active configuration or application tables. +- Work around a public-route failure by retrying against an internal module + reference. Report the public `not_found`, `ambiguous`, `unsupported`, or + `protocol_incomplete` result so the adapter can be corrected. + +## Write-result language + +Use the adapter’s `configurator_refresh` object verbatim in human-facing +instructions. Do not collapse `object`, `extension`, and `configuration` into +the same generic “restart” advice. diff --git a/docs/1c-sql-protocol/rules/evidence-and-safety.md b/docs/1c-sql-protocol/rules/evidence-and-safety.md new file mode 100644 index 0000000..ea3187b --- /dev/null +++ b/docs/1c-sql-protocol/rules/evidence-and-safety.md @@ -0,0 +1,55 @@ +# Evidence and safety rules + +## Evidence levels + +| Level | Meaning | Permitted use | +|---|---|---| +| Observed | Read from live SQL once | Diagnosis only | +| Reproduced | Seen in independent before/after saves | Decoder rule candidate | +| Round-trip proven | Decode → unchanged encode returns identical bytes | Safe read/transform component | +| Activation proven | A human Configurator applied it and active SQL verified it | Controlled `upo_test` writer component | + +No rule may be promoted because a name, suffix, or payload shape “looks right”. + +## Codec boundary + +The configuration-storage specification is the adapter's sole authority for +decoding and encoding. A writer is enabled only when the relevant version of +that specification defines every changed byte and every dependent integrity +atom, and round-trip evidence proves the encoder. Any remaining opaque atom is +preserved byte-for-byte; if a requested edit requires changing it, the write is +unsupported until the specification is extended by a controlled experiment. + +## Allowed mutation boundary + +- The adapter runtime uses SQL only. +- `upo_test` may receive controlled writes to `ConfigSave` and + `ConfigCASSave` only. +- `Config`, `ConfigCAS`, and application data are never direct write targets. +- A write must have a live target resolution, optimistic SHA-1 precondition, + reversible evidence, atomic transaction, and readback verification. +- Repository coordination is a separate policy; an adapter marker is not a + native repository lock. + +## Exact edits + +For BSL fragment replacement, provide one of: + +- an old fragment occurring exactly once; +- a larger unique surrounding fragment; +- a proven structural container path/offset plus original SHA-1. + +If a fragment occurs zero or more than once, do not choose arbitrarily. Return +the count and candidate contexts. Deletion follows the same rule. + +## What an agent must report + +Every proposal and applied result must state: + +- active and saved layers used; +- public target and SQL evidence retained internally; +- original and new SHA-1 values; +- paired files changed; +- exact-match count; +- rollback reference; +- Configurator refresh guidance. diff --git a/docs/1c-sql-protocol/templates/experiment-record.md b/docs/1c-sql-protocol/templates/experiment-record.md new file mode 100644 index 0000000..6a5f1c4 --- /dev/null +++ b/docs/1c-sql-protocol/templates/experiment-record.md @@ -0,0 +1,41 @@ +# Protocol experiment record template + +Create one file per material experiment under `experiments/`. + +```markdown +# + +## Scope +- Base and classification: +- Extension/object public names: +- Runtime/platform build: +- Authority for mutation: + +## Before +- Active source table/key/SHA-1: +- Saved-state files and SHA-1: +- Relevant descriptor/map entries: + +## Exact action +- Public target resolution evidence: +- Old fragment / structural selector and occurrence count: +- Payload codec and round-trip result: +- Paired files written in one transaction: + +## After SQL evidence +- Readback SHA-1 values: +- Map/reference validation: +- Unrelated pending files preserved: + +## Human Configurator verification +- Reload action: +- Visible result: +- Apply result: +- Active/saved postcondition: + +## Outcome +- Proven fact: +- Failed hypothesis: +- Remaining unknown: +- Follow-up regression fixture/test: +``` diff --git a/docs/1c-write-handler-architecture.md b/docs/1c-write-handler-architecture.md new file mode 100644 index 0000000..b14e9b0 --- /dev/null +++ b/docs/1c-write-handler-architecture.md @@ -0,0 +1,121 @@ +# 1C Adapter: universal write dispatcher and typed handlers + +## Decision + +The public write contract stays universal and name-first: + +```text +code.write / metadata.write + -> resolve public object and extension layer + -> plan and gates + -> select one typed handler + -> prepare saved state internally when required + -> apply, SQL-readback, rollback evidence +``` + +The caller never selects an SQL table, saved file, stream, payload codec, or +handler. If no handler has a proven capability for the requested object type +and operation, the dispatcher returns `unsupported_write_target` with a public +explanation. It must not fall back to a generic byte rewrite. + +## Current state + +The behaviour is already logically separated, but is physically concentrated +in `plugins/1c/connector/adapter_1c_server.py` (about 3.5 MB). The main +dispatcher is `metadata_write` and currently branches to: + +| Public target | Existing internal writer | +|---|---| +| BSL module | `metadata_module_write_apply` | +| BSL embedded in a managed form | `form_embedded_module_handler_write_apply` | +| Form element/property | `metadata_form_element_write_apply` | +| Form command/button caption | `metadata_form_command_button_write` | +| Scalar object/member property | `metadata_object_property_write` | +| Add object member | `metadata_object_member_add` | +| Scheduled-job schedule | `metadata_scheduled_job_schedule_write` | + +This is a suitable functional base. The problem is coupling: routing, +saved-state preparation, result shaping, codecs, SQL writes, and HTTP/RPC +dispatch live in one module, so a change in one type is too likely to affect +another. + +## Target module layout + +```text +plugins/1c/connector/ + adapter_1c_server.py # HTTP, RPC registration, composition root only + write/ + contracts.py # WriteIntent, WritePlan, WriteResult, capability errors + dispatcher.py # universal metadata.write dispatch; no SQL codecs + gates.py # layer, repository, optimistic-hash and mode gates + saved_state.py # Config→ConfigSave / ConfigCAS→ConfigCASSave prepare + receipt rollback + registry.py # handler registration and deterministic selection + handlers/ + module.py + embedded_form_module.py + form_element.py + form_command.py + object_property.py + object_member.py + scheduled_job.py + unsupported.py + storage/ + sql_saved_state.py # transactions, guarded row copy, backup/readback + extension_routes.py # active-to-saved route and cache refresh +``` + +`parser/` remains the place for pure payload decoding/encoding. A handler may +use a parser codec only where its round-trip proof exists; SQL access is +provided through a narrow context rather than imported globals. + +## Handler contract + +Each handler implements the same four operations: + +1. `can_handle(intent, evidence) -> supported | unsupported | ambiguous`. +2. `plan(intent, evidence) -> WritePlan` with exact guards and no mutation. +3. `apply(plan, context) -> WriteResult` only after shared gates succeed. +4. `rollback(result, context)` when the handler created reversible state. + +The dispatcher selects exactly one handler. Zero handlers yields +`unsupported_write_target`; multiple handlers yield `ambiguous_write_handler`. +Handlers never select another extension layer after dispatch. + +`metadata.write.capabilities` also returns `registered_handlers`. This makes +the runtime registry visible beside the broader capability matrix and prevents +an API claim from silently drifting away from the installed handlers. + +## Invariants owned centrally + +- public name/ref and extension scope resolve before handler selection; +- only saved layers are writable; +- saved-state preparation is internal and idempotent; +- optimistic hash, audit event, backup/receipt, SQL readback and rollback + policy are common infrastructure; +- SQL readback is not described as Configurator activation; +- low-level storage fields are redacted from name-first responses. + +## Safe migration order + +1. **Completed:** add storage-free `write/contracts.py`, typed handler + declarations under `write/handlers/`, and `write/registry.py`; connect + `metadata.write` to the registry while delegating to existing writers + unchanged. The registry has a deny-by-default result for unknown target + kinds. +2. **In progress:** add `write/context.py`; the scheduled-job route now enters + its typed handler through this explicit context. The handler still delegates + to the single existing implementation until its body moves in one change. +3. Move `saved_state.py` and `storage/sql_saved_state.py` first. The recently + proven hash-keyed extension prepare/rollback smoke is its acceptance test. +4. Extract the least coupled handlers: scheduled job, object property, object + member. +5. Extract form element and form command handlers. +6. Extract module and embedded-form-module handlers last; retain their exact + payload codec and paired-write tests. +7. Reduce `metadata_write` to validation, plan/gate orchestration and one + registry call. Keep the old public API names and response schema intact. + +Do not split by copying code into parallel paths. Each extraction must move one +authoritative implementation, keep the existing tests green, and add one +handler-level `plan → apply_and_rollback → readback` test in `upo_test` when a +matching fixture exists. diff --git a/docs/adapter-observer-handoff.md b/docs/adapter-observer-handoff.md new file mode 100644 index 0000000..54e5776 --- /dev/null +++ b/docs/adapter-observer-handoff.md @@ -0,0 +1,224 @@ +# Аналитика адаптера 1С: передача проекта + +Дата актуализации: 2026-08-07. + +## Назначение + +`adapter-observer` — независимый read-only веб-интерфейс аналитики для SQL-only адаптера 1С. Он читает журналы REST и MCP, получает разрешённые снимки метаданных через публичный API адаптера и не имеет прямого доступа к SQL-базе 1С. + +Сервис не является зависимостью `adapter-1c-rest` или `adapter-1c-mcp`: остановка либо обновление observer не должна влиять на работу адаптера. + +## Описание адаптера 1С + +Адаптер 1С — SQL-only сервис для чтения метаданных и выполнения строго контролируемых операций с saved-state конфигурации 1С. Он работает через подтверждённые структуры SQL-хранилища; не запускает Configurator и не должен выдумывать метаданные, маршруты или двоичные payload. + +Основные части: + +- `adapter-1c-rest` — REST API адаптера; +- `adapter-1c-mcp` — MCP-шлюз, который вызывает REST API; +- `adapter-1c-audit` — аудит-контур REST; +- `adapter-observer` — независимая аналитика журналов и разрешённых read-only вызовов. + +Код адаптера находится в текущем репозитории: + +```text +plugins/1c/connector/adapter_1c_server.py реализация REST/RPC методов +plugins/1c/connector/contracts/openapi.yaml публичный HTTP-контракт +plugins/1c/mcp/adapter_1c_mcp.py MCP-шлюз +plugins/1c/observer/ аналитика адаптера +``` + +### Где развёрнут адаптер + +| Контур | REST | MCP | Docker-хост | Назначение | +| --- | --- | --- | --- | --- | +| Production / внешний | `http://docker.cin.su:8011` | `http://docker.cin.su:8021/mcp` | `docker.cin.su` | Основной внешний адаптер и observer. | +| Staging / тестовый | `http://docker-test.cin.su:8011` при наличии тестового стека | `http://docker-test.cin.su:8021/mcp` при наличии тестового стека | `test-docker` | Проверка перед production. | +| Изолированная тестовая база | `base_id=upo_test` | через соответствующий MCP | выбранный контур | Разрешены контролируемые тесты и rollback. | + +Production-контейнеры на `docker.cin.su`: + +```text +adapter-1c-rest порт 8011 +adapter-1c-mcp порт 8021 +adapter-1c-audit внутренний аудит REST +adapter-observer порт 8031 +``` + +## Как проверять новые функции адаптера + +### До развёртывания + +1. Изменить реализацию в `plugins/1c/connector/adapter_1c_server.py` и зафиксировать публичный контракт в `plugins/1c/connector/contracts/openapi.yaml`. +2. Добавить либо обновить unit/smoke-тест в `tests/1c/` или `scripts/smoke_1c_*.py`. +3. Для новой метадаты или SQL-маршрута сначала получить доказательства из live SQL в `upo_test`; при неполном codec вернуть `unsupported`/`protocol_incomplete`, а не предполагать данные. +4. Прогнать тесты и smoke-проверку на `upo_test`. + +### Staging-проверка + +Развернуть обновлённые `adapter-1c-rest` и при необходимости `adapter-1c-mcp` на `test-docker`. Не использовать staging вместо external MCP production без явного запроса. + +Проверять новый read-only метод через REST/RPC с явным `base_id=upo_test`. Проверка write-маршрута должна пройти обязательные plan/preflight/apply/rollback-gates и не даёт права заявлять, что Configurator принял или активировал изменение. + +Полезные проверки: + +```powershell +# Контейнеры и порты выбранного контура +docker --host ssh://test-docker ps --format '{{.Names}} {{.Image}} {{.Status}} {{.Ports}}' + +# Контракт/доступные методы на REST +Invoke-WebRequest -UseBasicParsing http://docker-test.cin.su:8011/methods + +# Health observer после тестового вызова +Invoke-WebRequest -UseBasicParsing http://docker-test.cin.su:8031/health +``` + +Пути и аргументы нового метода нельзя составлять по догадке: использовать только его документированный контракт и подтверждённые публичные селекторы. + +## Как обновлять аналитику вместе с адаптером + +Каждое изменение адаптера нужно оценивать как изменение наблюдаемого контракта. + +| Изменение адаптера | Что изменить в observer | +| --- | --- | +| Новый метод аудита или новый статус | Проверить `event_view`, фильтр статусов, группировку summary и русские подписи. | +| Новый read-only метод для объекта | Добавить его в жёсткий allowlist `/api/object/action` только после проверки публичного селектора и безопасного ответа. | +| Новый тип метаданных | Добавить его в `treeGroups`, если он должен быть виден в дереве. | +| Новое поле длительности | Оставить в API машинское значение, а в UI провести через `duration()`. | +| Изменение схемы audit JSONL | Сохранить обратную совместимость: неизвестные поля показывать только в деталях, отсутствующие поля считать необязательными. | +| Новый write-маршрут | Не добавлять кнопку выполнения в observer. Допустимо отобразить только подтверждённую capability/статус после отдельного проектного решения. | + +Обязательная последовательность релиза: + +1. Сначала обновить адаптер и проверить его новый метод на `upo_test`. +2. Убедиться, что REST/MCP audit содержит безопасную запись вызова без SQL, BSL, payload и секретов. +3. Обновить observer в staging; открыть новый сценарий в UI и проверить, что метод не классифицируется как `exception` ошибочно. +4. Обновить observer на production вместе с совместимой версией адаптера. +5. Проверить `/health`, журнал, аналитику и конкретный объект в дереве. + +Observer не должен требовать одновременный рестарт адаптера. При выпуске только frontend/observer достаточно пересоздать `adapter-observer`; REST и MCP остаются запущенными. + +## Что сделано + +### Интерфейс + +- Журнал REST-запросов с фильтрами по методу, базе, статусу, периоду и минимальной длительности. +- Аналитика p50/p95, медленных методов, исключений и ожидаемых безопасных отказов. +- Корреляция MCP ↔ REST по `request_id`. +- Дерево метаданных конфигурации с разделом «Справочники». +- Для каждого доступного справочника отображаются read-only действия: + - Карточка; + - Свойства; + - Реквизиты; + - Формы; + - Команды; + - Модули; + - Макеты; + - Связи. +- Результат действия открывается в диалоге с названием операции, объектом, статусом и длительностью. +- Поиск по уже загруженному списку справочников и счётчик `Показано: N из M`. + +### Время выполнения + +- Во всех пользовательских представлениях миллисекунды форматируются в секунды, минуты и часы. +- Фильтр минимальной длительности вводится в секундах. +- В технических API-полях сохраняется `duration_ms`: это контрактное машинное значение, не пользовательская подпись. + +### Производительность + +- Observer отдаёт до 1000 объектов за один запрос к `metadata.objects.list`. +- Для базы `upo` загружается 798 доступных справочников из 825 объектов одного типа одним запросом; 27 объектов скрыты адаптером как отсутствующие/нечитаемые. +- Проверенное время live-сканирования этого списка: около 18,7 секунды. Это время адаптера и SQL-чтения, а не рендеринга кнопок в браузере. + +### Безопасность + +- Observer вызывает только жёстко заданный allowlist read-only методов для строки справочника. +- Новые действия не выполняют запись, подготовку saved-state, активацию конфигурации или операции Configurator. +- В интерфейсе не восстанавливаются исторические запросы из audit JSONL. + +## Что ещё нужно сделать + +Приоритетный следующий этап: + +1. Добавить быстрый серверный поиск справочника по имени, чтобы не ожидать полное live-сканирование при работе с одним объектом. +2. Добавить отображение прогресса при загрузке больших разделов: число прочитанных объектов, текущая страница и время ожидания. +3. Вынести перечень разрешённых действий и русские названия в отдельную конфигурацию/контракт, а не хранить в фронтенд-коде. +4. Добавить компактные пользовательские карточки результатов действий вместо показа полного JSON; JSON сохранить как диагностическую вкладку. +5. Добавить тесты UI/HTTP для сценария: открыть «Справочники» → загрузить → увидеть кнопки → выполнить «Карточка». +6. Добавить version/release marker в `/health` и UI, чтобы быстро отличать старую Docker-сборку от актуальной. +7. Добавить снимки и сравнение аналитики между релизами: список методов, покрытие метаданных, p50/p95 и изменения ошибок. +8. До публикации вне внутренней сети определить аутентификацию, роли, срок хранения audit-данных и экспортируемые поля. + +Не реализовывать без отдельного разрешения: + +- повтор исторических write/activation/repository-запросов; +- прямое подключение observer к SQL 1С; +- запуск или автоматизацию Configurator; +- вывод BSL-текста, SQL-полей, паролей или ключей из журналов. + +## Исходные файлы + +```text +plugins/1c/observer/ + observer_server.py HTTP API и безопасный allowlist действий + web/index.html оболочка интерфейса + web/assets/app.js UI, форматирование времени, дерево, действия + web/assets/style.css стили + Dockerfile образ observer + +core/deploy/docker/adapter-observer/ + compose.yaml отдельный Docker Compose стек + .env.example пример runtime-переменных + +docs/runbooks/adapter-observer.md + эксплуатационный контракт и ограничения +``` + +## Docker-развёртывание + +### Текущий production-хост + +- Docker host: `docker.cin.su`. +- Контейнер: `adapter-observer`. +- URL: `http://docker.cin.su:8031/`. +- Образ: `adapter-observer:latest`. +- Порт: `8031`. +- Внешняя сеть адаптера: `adapter-1c_default`. +- Read-only тома журналов: + - `adapter-1c_adapter-1c-data` → `/audit:ro`; + - `adapter-1c-mcp_adapter-1c-mcp-data` → `/mcp-audit:ro`. +- Собственный state-том: `adapter-observer_adapter-observer-state`. + +### Команда обновления + +Из корня текущего репозитория: + +```powershell +$env:DOCKER_HOST = 'ssh://docker.cin.su' +docker compose ` + --project-directory 'Z:\codex\LLM\core\deploy\docker\adapter-observer' ` + -f 'Z:\codex\LLM\core\deploy\docker\adapter-observer\compose.yaml' ` + up -d --build adapter-observer +``` + +После обновления: + +```powershell +Invoke-WebRequest -UseBasicParsing http://docker.cin.su:8031/health +docker --host ssh://docker.cin.su ps --filter 'name=^/adapter-observer$' +``` + +Команда пересоздаёт только `adapter-observer`. Не запускать `docker compose down` в проектах REST/MCP адаптера и не перезапускать `adapter-1c-rest` или `adapter-1c-mcp` ради обновления аналитики. + +### Тестовый хост + +Для staging используется тот же стек с `DOCKER_HOST='ssh://test-docker'` и URL `http://docker-test.cin.su:8031/`. + +## Проверки после переноса + +1. `GET /health` возвращает `status: ok` и показывает файлы REST/MCP audit. +2. Открыть «Дерево объектов» и загрузить `upo`. +3. Нажать «Читать» у «Справочники»: блок должен остаться раскрытым. +4. Убедиться, что видна строка вида `798 объектов · N с` без единицы `мс`. +5. У первой строки должны быть восемь кнопок действий. +6. Нажать «Карточка»: открывается диалог с успешным статусом и читаемой длительностью. diff --git a/docs/research/1c-code-vector-search-evaluation.md b/docs/research/1c-code-vector-search-evaluation.md new file mode 100644 index 0000000..7464ba1 --- /dev/null +++ b/docs/research/1c-code-vector-search-evaluation.md @@ -0,0 +1,54 @@ +# Проверка векторного поиска по коду 1С + +Дата проверки: 2026-07-26. База: `upo_test`. + +## Контур + +- актуальный SQL-derived индекс адаптера: 1 080 модулей, 22 538 chunks; +- очередь изменений: 0, snapshot чистый; +- модель: `Qwen3-Embedding-0.6B-GGUF`, `Q8_0`; +- runtime: `llama.cpp`, CPU-only на Ryzen 9 5900X; +- endpoint: `http://docker-gpu.cin.su:8082/v1/embeddings`; +- вектор модели: 1024, сохраняемый Matryoshka-срез: 384; +- RTX 4090 сервисом эмбеддингов не используется. + +## Измерения + +Прогретый короткий запрос к endpoint: 0,03–0,08 с. Индексация 100 процедур и +функций длиной до 1 000 символов заняла 54,52 с, ошибок и конфликтов нет. + +Контрольные запросы к строгому поиску: + +| Запрос | Top-1 | Время | +|---|---|---:| +| проверить является ли объект документом | `ЭтоДокумент` | 0,70 с | +| преобразовать число из строки | `ЧислоИзСтроки` | 0,44 с | +| добавить реквизит в HTML представление | `ДобавитьРеквизитКHTML` | 0,50 с | + +Каждый результат был повторно проверен по live SQL. Чтение `read_selector` +вернуло точное тело `ЭтоДокумент`, а не сохранённый текст из векторного кеша. + +## Решение по отдельной векторной БД + +Пока не добавлять. SQLite остаётся достаточным для текущего инкрементального +контура и проще связывает `chunk_id`, `text_sha1`, outbox и проверку +актуальности. Векторный кеш не является источником истины. + +Повторно оценить ANN-хранилище после заполнения не менее 5 000 актуальных +384-мерных vectors. Практические триггеры: + +- p95 строгого поиска выше 1 секунды; +- более 50 000 актуальных chunks; +- SQLite-файл адаптера больше 1 ГиБ из-за embeddings. + +Если триггер сработает, Qdrant/pgvector должен быть только производной копией: +ключ `chunk_id`, обязательный `text_sha1`, namespace базы/модели/размерности, +удаление через outbox. Перед ответом адаптер всё равно проверяет live SQL. + +## Выявленный следующий приоритет + +Глобально найденные активные модули пока часто имеют `object_ref=null`. +Безопасный `read_selector` и имя процедуры присутствуют, но публичное имя +владельца 1С не восстановлено. Это не проблема вектора; это неполный +`metadata.module_owner_cache`. Следующая доработка — фоновое построение +name-first карты владельцев и lazy backfill только для top-кандидатов. diff --git a/docs/runbooks/1c-agent-coding-contract.md b/docs/runbooks/1c-agent-coding-contract.md index b89e509..205a20b 100644 --- a/docs/runbooks/1c-agent-coding-contract.md +++ b/docs/runbooks/1c-agent-coding-contract.md @@ -1,7 +1,8 @@ # 1C Agent Coding Contract This contract is the default rule set for coding agents that work through the -1C adapter. +1C adapter. The agent calls it only through the `onec_request` MCP tool; the +REST SQL adapter is MCP's private downstream transport. ## Default View @@ -13,6 +14,57 @@ This contract is the default rule set for coding agents that work through the - Objects can exist only in saved-state and can later be activated or canceled. Do not hide them from the agent view. +### Empty saved-state layer + +An empty saved-state layer is normal before the first edit. In that case an +agent still uses only public selectors and reports the adapter result; it must +not obtain an active `ConfigCAS`/`Config` module reference and retry by hand. + +Known adapter limitation (2026-08-02): for an extension-owned object module +with no prepared saved-state row, public `code.read` can return +`source_missing` even though the active module is proven to exist. Treat this +as an adapter defect, not as evidence that the 1C object or BSL is absent. +Use a developer-owned diagnostic check to investigate it; do not expose its +storage coordinates to a coding agent. + +## Required adapter acceptance fixture + +`upo_test` must contain one isolated extension-owned `Report` with a decoded +`object_module`, a unique BSL anchor, and no shared business role. This fixture +is created and maintained by a human in Configurator; the SQL-only adapter must +not fabricate it. It is the required target for the public acceptance sequence: + +```text +code.read(ref, extension, module_ordinal=1) +→ code.write(old, new, apply_and_rollback) +→ code.search verifies new text +→ rollback and saved-state cleanup +``` + +The request must contain no `layer`, table, file name, module ref, payload hash, +or stream index. Until the fixture exists, unit tests prove routing only; they +do not prove a live extension Report write. + +### Verified base-module smoke + +On 2026-08-02 the public sequence was verified on `upo_test` against base +`Report.АвтоматическиеСкидки`, `module_ordinal=1`: a unique comment replacement +completed in about 25 seconds with `verified_and_rolled_back`. The adapter +auto-prepared `ConfigSave`, read back the write, rolled back the BSL change, +then removed the exact rows it had prepared. The public result contains the +opaque `prepare_receipt_id` and `prepare_cleanup`; the final saved-state status +was `empty`. This validates the base route only, not the extension Report route. +Live check on 2026-08-02 found 19 extensions but zero extension-owned Reports +in `upo_test`; therefore the required extension acceptance fixture is currently +missing and the extension write branch remains unaccepted. + +Live check on 2026-08-13 found an extension BSL stream for +`Report.УОП_ИнвентаризационнаяОпись`, but its `role_status=unconfirmed`. +That is read-only evidence, not an acceptance fixture: do not write through an +ordinal, storage reference, or guessed Configurator role. A human must add or +identify one extension Report with a decoded `object_module` before the live +extension `apply_and_rollback` test can run. + ## Compare Views - Use `state=both` or `source_state=all` only when the task needs a comparison @@ -33,6 +85,34 @@ Use public names and selectors: 3. `code.read` with `state=working` to read the module or routine. 4. `code.read` with `state=both` only for an explicit saved-vs-active check. +For a report form, keep the descriptions separate: + +1. `metadata.object.forms(ref=Report., source_state=working)` lists the + report's form references. +2. `metadata.object.form.details` with that same public report ref and the + returned form name reads the form description (attributes, parameters, + commands, items, and the form module). +3. `metadata.form.decode(view=structure)` is an optional compact static + projection. It must retain `unresolved` parent/child links where no SQL + codec has proved them. + +A form command does not have its own module. Its handler is a named routine in +the form module only when the decoder returns an evidenced command link. + +Use the `read_selector.selector_token` returned by discovery with the method +declared in that selector. Do not copy GUIDs, module refs, table names, or file +names into a follow-up request. A module ordinal, display name, or storage-derived role +is not a Configurator-tree path. If a result has `role_status=unconfirmed` or +`configurator_path_status=unconfirmed`, the agent must not name, write, or +infer its tree owner; report the missing decoder evidence instead. + +When checking object commands, call `metadata.object.commands` with the same +public `ref`/`kind`+`name` and `extension` selector. The adapter resolves the +extension name and reads its active metadata itself. A prior `not_found` from a +route that did not carry the extension context is not evidence that the report +has no commands. Conversely, a BSL stream suffix alone is never evidence of a +command or of its module path. + Agents should ask for and report object names, routine names, and code text. They should not ask users for SQL tables, storage file names, stream indexes, or saved-state write flags during normal coding work. @@ -52,12 +132,52 @@ Supported public edit shapes: say "save this code" and send the desired code text. It must not ask whether SQL saved-state apply flags are allowed. +For extension object modules, preparation maps the active payload route to its +canonical saved-state filename internally. A public `code.write` therefore +continues with `extension`, `ref`, module role, and the proven replacement +only; it must never ask the caller to supply the canonical filename or stream. + Every successful `code.write` response must show: - `write_mode.target=saved_state`; - `write_mode.activation_state=not_activated`; - `write_mode.production_apply=false`. +### Repository-controlled extension writes + +For a repository-controlled extension, use the public sequence below and stop +when it asks for a human Configurator action: + +```text +code.search(extension, ref, old fragment) +→ repository.lock.plan(extension, ref) +→ repository.lock.request +→ human captures the exact object in Configurator +→ repository.lock.confirm +→ code.write(extension, ref, module_ordinal, old, new, repository_lock) +→ code.search readback +``` + +The lock plan for `upo / фс_Отчеты1 / +Report.фс_ПродовольственнаяКорзина` resolves exactly one object: +`Отчет.фс_ПродовольственнаяКорзина`, in extension layer +`extension:8e02accd-8a34-11f1-8294-005056b0d483`. SQL cannot prove a native +Configurator capture; only a human confirmation can continue this route. + +`metadata.write.preflight` currently plans canonical high-level +`metadata.write` targets and may return `needs_route` for this compatibility +`code.write` shape before saved-state preparation. This is not permission to +use its low-level payload hint. Use `code.write(mode=plan)` instead: a first +extension edit may return public `needs_prepare`, which means to confirm the +repository lock and reissue the same public request in an apply mode. Keep +using the public `code.write` route; report any different route failure to +adapter developers. + +If it returns `extension_saved_state_prepare_protocol_unproven`, do not retry +or supply technical coordinates. The adapter has not yet proven the exact +saved-state preparation codec for this extension layout; report it for adapter +development. Do not infer that the extension was not saved in Configurator. + ## Hidden Storage Details The form module container marker `///----` is adapter-owned storage syntax. diff --git a/docs/runbooks/1c-code-embeddings.md b/docs/runbooks/1c-code-embeddings.md new file mode 100644 index 0000000..eb530fe --- /dev/null +++ b/docs/runbooks/1c-code-embeddings.md @@ -0,0 +1,132 @@ +# Актуальный векторный поиск по коду 1С + +Цель: семантический поиск по локальному SQL-индексу адаптера без потери +актуальности кода. Вектор является только производным кешем: перед выдачей +адаптер сверяет найденные фрагменты с текущим состоянием конфигурации и +отбрасывает либо переиндексирует устаревшие записи. + +## Выбранная модель + +- `Qwen/Qwen3-Embedding-0.6B-GGUF`, квантование `Q8_0`; +- OpenAI-compatible endpoint на `http://docker-gpu.cin.su:8082`; +- `llama.cpp`, `--embedding --pooling last`; +- CPU-only (`--n-gpu-layers 0`), чтобы не менять работающие GPU-сервисы; +- endpoint возвращает 1024 измерения, клиент использует Matryoshka-срез до + запрошенных 384 измерений и повторно нормализует его. + +Образ `llama.cpp` зафиксирован digest, а модель — официальным repository/quant +селектором. Это исключает незаметную смену runtime при повторном deploy. + +Модель и образ публичные, endpoint работает в изолированном тестовом контуре +без токена. Постоянный кеш модели хранится вне git в +`Z:\LLM\models\cache\llama.cpp`. + +## Развёртывание + +Проверить итоговую конфигурацию: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy_embeddings.ps1 -ConfigOnly +``` + +Запустить сервис с загрузкой образа: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy_embeddings.ps1 -Pull +``` + +Первый запуск скачивает модель в постоянный кеш и поэтому может занять +несколько минут. Скрипт ждёт `/health`, проверяет имя модели и делает реальный +запрос к `/v1/embeddings`. + +## Обновление векторов + +Сначала адаптер должен содержать актуальные текстовые chunks. Затем worker +забирает только отсутствующие либо изменившиеся фрагменты: + +```powershell +python scripts/embed_1c_code_vectors.py ` + --adapter-url http://docker.cin.su:8011/rpc ` + --base-id upo_test ` + --embedding-provider openai-compatible ` + --embedding-model qwen3-embedding-0.6b ` + --dimensions 384 ` + --embedding-base-url http://docker-gpu.cin.su:8082 ` + --limit 500 ` + --batch-size 8 ` + --chunk-kind routine ` + --max-text-chars 4000 ` + --json +``` + +Worker по умолчанию индексирует `routine`: процедуры и функции дают наиболее +точный контекст для программирования и заметно быстрее пересчитываются при +частых изменениях. Для диагностического покрытия модульных фрагментов можно +добавить второй `--chunk-kind module`; это более дорогой отдельный проход. +`--max-text-chars` не обрезает код молча: длинные chunks пропускаются в этом +проходе и остаются pending. Их нужно разбивать на окна отдельной задачей либо +индексировать в период низкой нагрузки с большим лимитом. + +Размерность входит в имя кеша (`openai-compatible:qwen3-embedding-0.6b@d384`), +поэтому векторы разных размеров никогда не смешиваются. + +## Поиск + +```powershell +python scripts/search_1c_code_vectors.py ` + "где рассчитывается сумма документа перед проведением" ` + --adapter-url http://docker.cin.su:8011/rpc ` + --base-id upo_test ` + --embedding-provider openai-compatible ` + --embedding-model qwen3-embedding-0.6b ` + --dimensions 384 ` + --embedding-base-url http://docker-gpu.cin.su:8082 ` + --limit 10 ` + --json +``` + +Поиск вызывается с `strict=true` и `verify=true`. Сохранённые изменения +Конфигуратора перекрывают активную конфигурацию, а удалённые/изменённые chunks +не возвращаются по старому вектору. + +Для активного кода расширений хеш `ConfigCAS` разрешается через текущий +manifest расширения в descriptor объекта. Результат содержит обычные +`kind/name`, а также `extension` и `extension_guid`; вызывающему коду не нужно +работать с CAS-хешами как с именами объектов. + +Перед первым глобальным поиском после обновления адаптера нужно постранично +заполнить локальную карту владельцев: + +```json +{ + "method": "metadata.module_owner_cache.backfill", + "payload": { + "base_id": "upo_test", + "limit": 50, + "kind_index": 0, + "offset": 0 + } +} +``` + +Следующий вызов получает `kind_index` и `offset` из `next_cursor`. Повторять до +`complete=true`. Операция читает актуальные метаданные, но пишет только в +локальный SQLite адаптера. Найденные имена сразу добавляются в существующие +строки лексического и векторного индексов; перестраивать embeddings не нужно. +По умолчанию объекты без строк code index быстро пропускаются. `deep=true` +нужен только для отдельного фонового заполнения владельцев неиндексированных +модулей и не должен использоваться в интерактивном поиске. + +Для `Qwen3-Embedding` клиент автоматически добавляет к векторизуемому запросу +англоязычную инструкцию поиска по исходному коду 1С, как рекомендует карточка +модели. В `query` адаптера остаётся исходный русский текст, поэтому лексическая +часть гибридного поиска не загрязняется служебным префиксом. + +## Отдельная векторная БД + +На текущем этапе не требуется. Векторы хранятся рядом с индексом адаптера в +SQLite и выбираются линейным сканированием. Это проще и гарантирует атомарную +проверку актуальности. Отдельный ANN-движок имеет смысл только после замера +десятков тысяч актуальных chunks и неприемлемой задержки; источником истины всё +равно остаётся 1С/SQL, а ANN должен хранить `chunk_id` и `text_sha1` как +проверяемую производную копию. diff --git a/docs/runbooks/1c-form-command-binding-learning.md b/docs/runbooks/1c-form-command-binding-learning.md index 149d4fb..23a054c 100644 --- a/docs/runbooks/1c-form-command-binding-learning.md +++ b/docs/runbooks/1c-form-command-binding-learning.md @@ -6,7 +6,7 @@ form decoder reached zero missing items and zero mismatches. ## Target - Base: `upo_test` -- Adapter: `http://docker-gpu.cin.su:8011` +- Adapter: `http://docker.cin.su:8011` - Saved-state table: `ConfigCASSave` - Form payload file: `f96a0c45-3eff-11f1-8287-005056b0d483__25c39fbf-35a4-4b43-8e3f-cd1f91082c88.0` @@ -104,7 +104,7 @@ payload = { "timeout_seconds": 60, "max_items": 5000 } -req = urllib.request.Request("http://docker-gpu.cin.su:8011/rpc", data=json.dumps({"method":"metadata.write_learning.capture_after","payload":payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"}) +req = urllib.request.Request("http://docker.cin.su:8011/rpc", data=json.dumps({"method":"metadata.write_learning.capture_after","payload":payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"}) print(urllib.request.urlopen(req, timeout=90).read().decode("utf-8")) '@ | python - ``` @@ -116,7 +116,7 @@ Then run: import json, urllib.request for method in ("metadata.write_learning.diff", "metadata.write_learning.infer_rule"): payload = {"learning_id": "form-command-binding-standard-customize-form"} - req = urllib.request.Request("http://docker-gpu.cin.su:8011/rpc", data=json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"}) + req = urllib.request.Request("http://docker.cin.su:8011/rpc", data=json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"}) print(urllib.request.urlopen(req, timeout=90).read().decode("utf-8")) '@ | python - ``` @@ -151,7 +151,7 @@ payload = { "timeout_seconds": 60, "max_items": 5000 } -req = urllib.request.Request("http://docker-gpu.cin.su:8011/rpc", data=json.dumps({"method":"metadata.write_learning.capture_after","payload":payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"}) +req = urllib.request.Request("http://docker.cin.su:8011/rpc", data=json.dumps({"method":"metadata.write_learning.capture_after","payload":payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"}) print(urllib.request.urlopen(req, timeout=90).read().decode("utf-8")) '@ | python - ``` @@ -163,7 +163,7 @@ Then run: import json, urllib.request for method in ("metadata.write_learning.diff", "metadata.write_learning.infer_rule"): payload = {"learning_id": "form-command-binding-local-apply-command"} - req = urllib.request.Request("http://docker-gpu.cin.su:8011/rpc", data=json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"}) + req = urllib.request.Request("http://docker.cin.su:8011/rpc", data=json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8"), headers={"Content-Type":"application/json"}) print(urllib.request.urlopen(req, timeout=90).read().decode("utf-8")) '@ | python - ``` diff --git a/docs/runbooks/1c-form-discovery.md b/docs/runbooks/1c-form-discovery.md index d93c95f..92e2574 100644 --- a/docs/runbooks/1c-form-discovery.md +++ b/docs/runbooks/1c-form-discovery.md @@ -1,5 +1,11 @@ # 1C Form Discovery And Editing +The semantic object/form boundary for agent responses is defined in +[`1c-metadata-structure.md`](1c-metadata-structure.md). In particular, an +owner returns references to forms; form parameters, attributes, commands, +elements, and form-module facts are read through the form's own public +selector. Do not duplicate a decoded form as an invented subtree of its owner. + This runbook adapts the MOXCEL discovery loop to managed forms. The goal is a full SQL-side form decoder and safe saved-state editing through the test extension, with XML exports used only as evidence fixtures. @@ -7,7 +13,7 @@ extension, with XML exports used only as evidence fixtures. ## Current Baseline - Default base: `upo_test`. -- Default adapter endpoint: `http://docker-gpu.cin.su:8011`. +- Default adapter endpoint: `http://docker.cin.su:8011`. - Primary test extension/object fixture: `фс_ДоработкиОбщее` / `DataProcessor.фс_НастройкаУсловногоОформления`. @@ -142,7 +148,7 @@ Decode a concrete saved-state form payload: ```powershell python scripts/smoke_1c_write_matrix.py ` - --base-url http://docker-gpu.cin.su:8011 ` + --base-url http://docker.cin.su:8011 ` --base-id upo_test ` --table ConfigCASSave ` --file-name ` @@ -177,7 +183,7 @@ Build a decoder coverage and gap profile for an object form: ```powershell python scripts/profile_1c_forms.py ` - --adapter-url http://docker-gpu.cin.su:8011 ` + --adapter-url http://docker.cin.su:8011 ` --base-id upo_test ` --kind Catalog ` --name ЗадачиАссистентаУправления ` @@ -223,7 +229,7 @@ Run the existing source-aware route smoke: ```powershell python scripts/smoke_1c_saved_state_write_routes.py ` - --base-url http://docker-gpu.cin.su:8011 ` + --base-url http://docker.cin.su:8011 ` --base-id upo_test ` --table ConfigCASSave ` --file-name ` @@ -234,7 +240,7 @@ Then run the matrix smoke: ```powershell python scripts/smoke_1c_write_matrix.py ` - --base-url http://docker-gpu.cin.su:8011 ` + --base-url http://docker.cin.su:8011 ` --base-id upo_test ` --table ConfigCASSave ` --file-name ` diff --git a/docs/runbooks/1c-metadata-structure.md b/docs/runbooks/1c-metadata-structure.md new file mode 100644 index 0000000..865c465 --- /dev/null +++ b/docs/runbooks/1c-metadata-structure.md @@ -0,0 +1,124 @@ +# Структура метаданных конфигурации 1С + +Этот документ задаёт модель, которой должны следовать агенты и публичные +ответы SQL-адаптера. Он описывает семантические метаданные, а не физические +имена SQL-записей, пути контейнеров или номера потоков. + +## Главное правило + +У конфигурации нет единого шаблона дочерних узлов для всех классов объектов. +Набор узлов определяется классом объекта и подтверждается двумя источниками: + +1. документацией платформы 1С для соответствующего класса; +2. живым чтением выбранного объекта через адаптер. + +Не добавляйте в ответ ни табличную часть, ни форму, ни модуль только потому, +что они есть у другого объекта. Если живой декодер не подтвердил конкретный +дочерний объект, верните явный неполный результат, а не пустой вымышленный +узел. + +## Связь объекта и формы + +Форма — самостоятельный объект метаданных, на который владелец ссылается в +своей коллекции `Формы`. В кратком описании владельца возвращается только +ссылка/идентичность формы: имя, GUID и доступный публичный селектор. Полное +описание формы читается отдельным запросом. + +```text +Отчет +└─ Формы + └─ <ссылка на Форму> + +Форма +├─ Реквизиты +├─ Параметры +├─ Команды +├─ Модуль формы +└─ Элементы +``` + +Параметры формы не являются её реквизитами. Это декларация параметризации при +открытии формы. Команда формы не имеет отдельного модуля: её имя обработчика +является свойством команды и разрешается в модуле формы. Аналогично имя +обработчика события элемента — свойство элемента, а не дочерний узел дерева. + +Подчинённая команда владельца (`Отчет.Команды.<Имя>`) отличается от команды +формы. Если документация и живая база подтверждают модуль команды, его надо +сообщать как свойство этой команды, не выдавая за форму или за модуль формы. + +## Отчёт + +Официальная методическая документация 1С подтверждает, что отчёт может иметь +реквизиты и табличные части. Табличная часть, в свою очередь, имеет реквизиты. +Для отчёта допустима следующая *классовая* схема; конкретные экземпляры +показываются только после живого чтения: + +```text +Отчет +├─ Реквизиты +├─ Табличные части +│ └─ Реквизиты табличной части +├─ Формы → отдельные описания Форм +├─ Команды +├─ Макеты +├─ Модуль объекта +└─ Модуль менеджера +``` + +СКД — это тип/содержимое макета, а не обязательная отдельная ветвь любого +отчёта. Не создавайте узел СКД, если в живом объекте не подтверждён +соответствующий макет. + +## Публичные маршруты адаптера + +Для объекта `Отчет.<Имя>` адаптер использует следующие раздельные операции: + +1. `metadata.object.forms` — читает подтверждённые ссылки отчёта на формы; + возвращаемые строки содержат идентичность формы, а не её элементы, + реквизиты, параметры или текст модуля. +2. `metadata.object.form.details` — по владельцу и имени формы получает + развёрнутое описание формы. +3. `metadata.form.decode` — декодирует одну конкретную форму и возвращает её + параметры, реквизиты, элементы, команды, события и сводку встроенного + модуля. + +Передавайте `configuration_view=effective_working` либо не передавайте view: +MCP установит этот режим сам. Он означает логическую рабочую конфигурацию с +учётом базовой конфигурации, сохранённых изменений и расширений; он не +разрешает агенту выбирать `ConfigSave` или `ConfigCASSave`. + +Таким образом, ссылка `Отчет → Форма` не должна заменяться копией описания +формы внутри объекта отчёта. Агрегированный `metadata.object.full` удобен для +обзора, но для работы с формой агент обязан сохранять её отдельный публичный +селектор и при необходимости вызвать один из двух form-методов выше. + +Текущая реализация подтверждает маршрут для `Report`: в +`RELATED_SECTION_RULES` форма имеет категорию `Form`, а +`metadata.object.forms` возвращает только `guid`, `name`, `synonyms` и счётчики +частей. Детали добавляет только `metadata.object.form.details` через отдельный +вызов `metadata.form.decode`. + +## Источники + +- [Иерархия объектов конфигурации и подчинённые объекты — 1С:EDT](https://its.1c.ru/db/content/edtdoc/src/topics/i030.html) +- [Отчёты, реквизиты и табличные части — методическая поддержка 1С](https://its.1c.ru/db/content/metod8dev/src/platform81/startersdev/i8102579.htm) +- [Формы: реквизиты, параметры, команды и модуль — руководство разработчика 1С](https://its.1c.ru/db/v8310doc/bookmark/dev/TI000000391) +- [Явное объявление параметров формы — стандарт 1С](https://its.1c.ru/db/content/v8std/src/1%C2%A0200/700/i8100741.htm) + +## Проверка перед изменением + +Перед тем как менять форму или модуль, агент обязан выполнить публичное +чтение владельца, затем получить форму её собственным селектором. Отсутствие +SQL-подключения, нераспознанный контейнер или неоднозначная связь — это +`unresolved`/`protocol_incomplete`, а не разрешение достроить структуру по +документации. + +`metadata.form.decode` с `view=structure` возвращает компактные доказанные +факты по статическим элементам. Пока SQL-кодек логического родителя не +доказан, поля `parent` и `children` имеют статус `unresolved`; нельзя строить +их из технической глубины или пути сериализованного контейнера. + +Запросы `runtime.form.elements.inspect` и `runtime.form.inspect` возвращают +`runtime_inspection_unsupported`. SQL-адаптер не открывает формы, не запускает +обработчики и не выдаёт созданные СКД во время работы элементы за статические +метаданные. diff --git a/docs/runbooks/1c-moxel-discovery.md b/docs/runbooks/1c-moxel-discovery.md index b7203fe..8c66268 100644 --- a/docs/runbooks/1c-moxel-discovery.md +++ b/docs/runbooks/1c-moxel-discovery.md @@ -240,7 +240,7 @@ changes: ```powershell python scripts\profile_1c_tabular_templates.py ` - --adapter-url http://docker-gpu.cin.su:8011 ` + --adapter-url http://docker.cin.su:8011 ` --base-id upo_test ` --inventory-json reports\1c-template-baselines\upo_test_configuration_tabular_templates.json ` --output-json reports\1c-template-baselines\upo_test_tabular_template_profiles.json ` diff --git a/docs/runbooks/1c-operational-coding.md b/docs/runbooks/1c-operational-coding.md index 0ae14ee..9e14cbe 100644 --- a/docs/runbooks/1c-operational-coding.md +++ b/docs/runbooks/1c-operational-coding.md @@ -116,8 +116,11 @@ SQL удобен как быстрый источник данных, но не canonical path до процедуры, областью является эта процедура/функция; иначе весь текущий saved-модуль. -По умолчанию `code.write` делает `mode=apply`, но это apply в saved-state -слой (`ConfigSave`/`ConfigCASSave`), а не применение конфигурации в runtime. +По умолчанию `code.write` делает безопасный `mode=plan` и не пишет в SQL. +Только явно переданный `mode=apply`, `apply_and_verify` или +`apply_and_rollback` может записать saved-state слой +(`ConfigSave`/`ConfigCASSave`); это всё равно не применение конфигурации в +runtime. Адаптер сам выставляет save-first gates и сам выбирает физический маршрут. Физические детали возвращаются только при `include_storage=true` для диагностики. Ответ `code.write` всегда содержит `write_mode`: target @@ -134,6 +137,62 @@ source `saved_state`, activation_state `not_activated`. добирается отдельным проходом, а `counts.saved_matches` и `counts.active_matches` показывают покрытие по слоям. +### Repository lock and first extension edit + +Для изменения объекта в расширении, подключенном к хранилищу, агент сначала +использует только публичные вызовы: + +```text +code.search → repository.lock.plan → repository.lock.request +→ человек захватывает объект в Конфигураторе → repository.lock.confirm +→ code.write → code.search (readback) +``` + +Если у extension-модуля ещё нет saved-state строки, `code.write(mode=plan)` +возвращает `needs_prepare` и +`diagnostics.next_action=confirm_repository_lock_then_apply`. Это нормальный +первый-edit маршрут: после подтверждённого lock тот же публичный `code.write` +в apply-режиме сам подготовит saved-state. Агент не передаёт `module_ref`, +`stream_index`, таблицу или имя технического файла. + +Исключение: `extension_saved_state_prepare_protocol_unproven` означает, что +автоматическая подготовка запрещена. Это не доказательство того, что +расширение не сохраняли: адаптер ещё не доказал точный prepare-кодек для +наблюдаемой extension-layout. Агент не создаёт контейнер через SQL и передаёт +случай разработчикам адаптера без технических координат. + +Acceptance extension write считается пройденным только при наличии в +`upo_test` отдельного extension-owned `Report` с object module и успешном +публичном `code.write(..., apply_and_rollback)` без storage-координат. Успех +base-модуля в `ConfigSave` не доказывает ветку `ConfigCAS → ConfigCASSave`. + +`upo` не используется для автоматических проверочных записей. Контролируемые +`apply_and_rollback` проверки разрешены только в `upo_test`. + +Для регрессии первого extension-edit используйте публичный smoke (без SQL +таблиц, key, module_ref или GUID в запросе): + +```powershell +python scripts\smoke_1c_extension_saved_state_prepare.py --apply +``` + +Он проверяет `plan → prepare/readback → rollback → immediate plan` на +`upo_test / фс_ДоработкиОбщее / Catalog.Номенклатура`. После rollback не +должно остаться saved-state строк, а повторный план должен быть `plan_ready`. + +### Safe adapter deployment + +Перед Docker-обновлением скрипт развёртывания запрашивает `/health` и ждёт +`runtime.state=ready` и `runtime.active_rpc_count=0`. При остановке REST +переходит в `draining`; уже начатые запросы продолжают выполняться до пяти +минут. Не используйте `-SkipDrainCheck`, кроме аварийного случая, когда +ответственный подтвердил отсутствие активной записи. + +После обновления проверяются REST `/health?base_id=upo_test` и MCP `/health`. +JSONL-аудит REST хранится в `/data/adapter-audit.jsonl`, MCP — в +`/data/mcp-audit.jsonl`; оба периодически сворачиваются в +`/data/adapter-audit-reports/latest.json` на соответствующем хосте. + Если фрагмент повторяется, агент должен передать более узкий контекст (`routine_name`) или заменить процедуру целиком. Адаптер в такой ситуации возвращает `ambiguous_fragment`, `scope` и `counts.occurrences`, и не diff --git a/docs/runbooks/adapter-1c-mcp.md b/docs/runbooks/adapter-1c-mcp.md index 46d19dd..d45f1b7 100644 --- a/docs/runbooks/adapter-1c-mcp.md +++ b/docs/runbooks/adapter-1c-mcp.md @@ -52,7 +52,7 @@ MCP server configuration. The MCP proxy does not hard-code the 1C adapter address. Pass it with: ```text -ONEC_ADAPTER_URL=http://docker-gpu.cin.su:8011 +ONEC_ADAPTER_URL=http://docker.cin.su:8011 ``` Optional adapter bearer token: @@ -75,6 +75,37 @@ core/deploy/docker/adapter-1c-mcp/.env.example For real deployment, create a non-committed `.env` next to the compose file and set the actual adapter URL/token there. +## REST adapter on docker.cin.su + +The REST SQL adapter is deployed from: + +```text +core/deploy/docker/adapter-1c/compose.yaml +``` + +Its published port is set in a non-committed REST environment file: + +```text +ADAPTER_1C_HOST_PORT=8011 +``` + +SQL-base administration is available in the REST adapter at: + +```text +http://docker.cin.su:/admin +``` + +With the default port this is `http://docker.cin.su:8011/admin`. The page is +used to add or update SQL connections for named bases. Do not put connection +passwords in Git or documentation. If the port changes, use the new port both +for this page and in `ONEC_ADAPTER_URL`. + +If that port is occupied, select a free port there and set the MCP URL to the +same value, for example `ONEC_ADAPTER_URL=http://docker.cin.su:18011`. +Keep `ONEC_SQL_BASES_JSON` (or the mounted `/data/onec-sql-bases.json`) only +in that external runtime configuration; never place SQL passwords in this +repository. + ## Deploy Deploy both REST adapter and MCP proxy, then run live verification when a test @@ -83,6 +114,8 @@ base is available: ```powershell powershell -NoProfile -ExecutionPolicy Bypass ` -File scripts\deploy_1c_adapter_stack.ps1 ` + -RestEnvFile ` + -McpEnvFile ` -BaseId ``` @@ -692,7 +725,7 @@ Saved-state BSL write smoke: ```powershell python scripts\smoke_1c_code_write_saved_state.py ` - --adapter-url http://docker-gpu.cin.su:8011 ` + --adapter-url http://docker.cin.su:8011 ` --base-id ` --extension ` --object-type CommonForm ` @@ -712,7 +745,7 @@ Agent working-view report: ```powershell python scripts\report_1c_agent_working_view.py ` - --adapter-url http://docker-gpu.cin.su:8011 ` + --adapter-url http://docker.cin.su:8011 ` --base-id ` --extension ` --object-type CommonForm ` @@ -738,7 +771,7 @@ Optional live selector-chain smoke against a real adapter/base: python scripts\smoke_1c_mcp_selector_chain.py ` --live ` --transport rest ` - --adapter-url http://docker-gpu.cin.su:8011 ` + --adapter-url http://docker.cin.su:8011 ` --base-id ` --json ` --no-report diff --git a/docs/runbooks/adapter-observer.md b/docs/runbooks/adapter-observer.md new file mode 100644 index 0000000..5b35bed --- /dev/null +++ b/docs/runbooks/adapter-observer.md @@ -0,0 +1,130 @@ +# Adapter Observer + +`adapter-observer` is an independent, read-only web service for adapter telemetry. +It must never connect to 1C SQL, call write methods, modify `/data`, or be a +runtime dependency of `adapter-1c-rest` or `adapter-1c-mcp`. + +## Inputs and guarantees + +- It mounts REST and MCP Docker volumes at `/audit:ro` and `/mcp-audit:ro` and + reads only rotated `adapter-audit.jsonl*` / `mcp-audit.jsonl*` files. +- The source events contain public selector summaries, status/error, request + correlation id, timings, and safe result summaries. They intentionally omit + BSL text, raw SQL payloads, storage keys, and credentials. +- A missing or malformed log is an Observer condition, never an adapter error. +- The UI distinguishes `exception` from expected safe outcomes such as + `blocked`, `unsupported`, and `invalid_argument`. + +## Tabs + +- **Журнал запросов**: filterable individual REST events and their safe details. +- **Аналитика**: p50/p95, slow method ranking, grouped non-OK fingerprints and + evidence-based next actions. +- **Покрытие**: contract for adding a future `metadata.adapter.audit` snapshot. + +## Retry boundary + +Audit JSONL deliberately has no complete request payload. Do not reconstruct +or guess it from selector summaries. A future replay button may repeat only a +request captured by Observer itself with an explicit read-only allowlist. It +must never replay a write request from logs. + +## Deployment + +On `docker.cin.su`, inspect the existing REST container and volume first: + +```text +docker inspect adapter-1c-rest +docker volume ls +``` + +Deploy only the Observer stack from `core/deploy/docker/adapter-observer`. +It uses external volume `adapter-1c_adapter-1c-data` read-only and port 8031 +by default. Do not run `down` against the adapter compose project. + +```text +docker compose --env-file .env -f compose.yaml up -d --build +curl http://localhost:8031/health +``` + +## Development contract + +When adapter telemetry changes, preserve backwards parsing: unknown fields are +shown in event details; known metrics remain optional. Before adding a special +visualization, record its input schema and add fixture JSONL tests. The general +journal must continue working for unknown adapter methods. + +## Key decisions for future agents + +1. Keep Observer a separate Compose project, port, image and failure domain. + Never add it as a dependency to REST/MCP and never restart those containers + while deploying it. +2. The volumes are externally named `adapter-1c_adapter-1c-data` and + `adapter-1c-mcp_adapter-1c-mcp-data` on `docker.cin.su`; Observer mounts + them only as `/audit:ro` and `/mcp-audit:ro`. +3. Audit events are evidence, not replay payloads. The historical journal can + link to an object by a public selector but cannot reconstruct omitted fields. +4. Treat job lifecycle statuses `accepted`, `running`, `done` and `cancelled` + as operational state, not failures. Expected rejections are shown separately + from adapter exceptions. +5. Retain both `duration_ms` (Observer-facing REST wall time) and optional + result timings. Do not manufacture nested timings if the adapter did not + return them; the first precise per-span waterfall requires an Observer-owned + read-only proxy/session trace. + +## Delivery plan + +### Delivered MVP + +- Rotated REST JSONL reader, safe event details and filters. +- p50/p95/max latency by method, error fingerprint grouping and guidance. +- Read-only Docker deployment and health endpoint. +- MCP-to-REST request correlation by `request_id`; absent REST pair is shown as + a transport boundary, not a decoder failure. +- A bounded read-only coverage refresh (`help.methods` and + `metadata.adapter.audit`) stored in Observer's own state volume, with the + latest 50 snapshots per base and a schema/method/count delta. +- A best-effort background coverage snapshot every 900 seconds for `upo_test`. + It has a 300-second minimum interval and must never affect UI availability. + +### Next safe increments + +1. Add an Observer-owned, read-only allowlist proxy. It can save complete + *sanitized* read request payloads for user-initiated replay and form a true + parent/child waterfall; no historical write replay. +2. Periodically invoke `metadata.adapter.audit` through that proxy and persist + versioned coverage snapshots in an Observer-owned SQLite database. +3. Add regression screens: compare coverage/schema/method catalog before and + after an adapter release, with an explicit `not comparable` state. +4. Add retention, export and role controls before exposing the journal outside + the internal network. + +### Explicit non-goals until separately approved + +- No replay of historical write, activation, repository or password operations. +- No raw request/response capture solely to make replay convenient. +- No automatic remediation, SQL optimization, Configurator launch or mutation. +- No claim that a slow request is an adapter defect without repeated evidence. + +### Items commonly forgotten in observability work + +- **Clock semantics:** preserve source UTC timestamp and Observer receive time; + never compare durations across hosts as if clocks were synchronized. +- **Asynchrony:** display job poll calls separately from end-to-end job time; + polling volume must not dominate failure charts. +- **Cardinality:** bound selector/error fingerprints so arbitrary object names + cannot create an unbounded metrics index. +- **Release correlation:** record Observer version and adapter health/method + catalog snapshot beside each periodic coverage run. +- **Availability:** health, disk/read errors and log rotation failures of + Observer must appear in its own diagnostics, not as adapter failures. +- **Retention and access:** define data lifetime, exported fields and viewer + permissions before making port 8031 internet-facing or adding Caddy routes. + +## Review checklist + +- Does a proposed feature work with omitted payload fields rather than guessing? +- Does it classify expected rejection separately from exception/transport loss? +- Does it preserve `request_id` and public selector provenance? +- Is every newly persisted field redacted and bounded by retention? +- Can the Observer be stopped without affecting adapter requests? diff --git a/docs/runbooks/additional-attributes-runtime-bridge.md b/docs/runbooks/additional-attributes-runtime-bridge.md new file mode 100644 index 0000000..8dcaec6 --- /dev/null +++ b/docs/runbooks/additional-attributes-runtime-bridge.md @@ -0,0 +1,50 @@ +# Прикладной эталон дополнительных реквизитов для разработки + +## Цель + +Опубликовать в изолированной базе `upo_test` read-only endpoint, который +читает ПВХ средствами платформы 1С. Это эталон для разработки и регрессионного +сравнения SQL-адаптера; адаптер к endpoint не подключается. + +## Изолированное расширение + +1. В Конфигураторе создайте новое расширение `AdapterAdditionalAttributesBridge`. +2. Добавьте серверный общий модуль `ДополнительныеРеквизитыReadOnly` и вставьте + содержимое `plugins/1c/bridge/additional_attributes_readonly.bsl`. +3. Добавьте HTTP-сервис `AdapterRuntimeBridge` с URL `/runtime-bridge`. +4. Добавьте URL-шаблон `rpc`, метод `POST`, и обработчик + `ДополнительныеРеквизитыHTTP.ОбработатьRPC`. +5. Добавьте серверный модуль `ДополнительныеРеквизитыHTTP` и вставьте + `plugins/1c/bridge/additional_attributes_http_handler.bsl`. +6. Ограничьте публикацию тестовой сетью и отдельным техническим пользователем + только с правами чтения ПВХ и справочника `СтруктурныеЕдиницы` вместе с его + табличной частью `ДополнительныеРеквизиты`. +7. Обновите конфигурацию базы из расширения и опубликуйте HTTP-сервис. + +Не меняйте существующие `Chatbot`, биллинг, телефонию или сервисы обмена. + +## Проверка endpoint + +```json +{"method":"additional_attributes.find","payload":{"base_id":"upo_test","query":"Ответственное направление","include_deleted":false,"limit":10}} +``` + +Ожидается `status=found` и как минимум `ref`, `description`, +`identifier_for_formula`, `property_set`, `value_type`. + +Затем: + +```json +{"method":"additional_attributes.storage.resolve","payload":{"base_id":"upo_test","property_ref":"","owner_ref":"Catalog.СтруктурныеЕдиницы"}} +``` + +Ожидается `status=confirmed` и источник +`Справочник.СтруктурныеЕдиницы.ДополнительныеРеквизиты`. + +## Использование результата + +Сравните ответ endpoint с `data.list`, `data.schema`, +`additional_attributes.find` и `additional_attributes.storage.resolve` SQL +адаптера. В код адаптера переносятся только подтверждённые общие правила +разбора метаданных и SQL-маршрута; адрес endpoint, учётные данные и вызовы +платформы в адаптер не добавляются. diff --git a/plugins/1c/agent/Dockerfile b/plugins/1c/agent/Dockerfile index 1ed4533..b9d5026 100644 --- a/plugins/1c/agent/Dockerfile +++ b/plugins/1c/agent/Dockerfile @@ -10,6 +10,7 @@ RUN pip install --no-cache-dir pyyaml==6.0.2 COPY scripts /app/scripts COPY plugins/1c /app/plugins/1c COPY config /app/config +COPY core /app/core COPY registry /app/registry EXPOSE 8090 diff --git a/plugins/1c/agent/agent_server.py b/plugins/1c/agent/agent_server.py index f6891de..5df0106 100644 --- a/plugins/1c/agent/agent_server.py +++ b/plugins/1c/agent/agent_server.py @@ -1003,6 +1003,12 @@ ADAPTER_BASE_ID_REQUIRED_PREFIXES = ( "storage.", "templates.", ) +AGENT_FORBIDDEN_TECHNICAL_SELECTOR_FIELDS = { + "table", "file_name", "file_names", "module_ref", "module_id", "stream_index", + "bsl_offset", "cas_key", "storage_key", "include_storage", "guid", "object_guid", + "form_guid", "extension_guid", +} +AGENT_CONFIGURATION_METHOD_PREFIXES = ("metadata.", "modules.", "code.", "templates.", "extension.") def adapter_method_requires_base_id(method: str) -> bool: @@ -1018,29 +1024,99 @@ def validate_adapter_call(method: str, params: dict[str, Any] | None) -> None: if adapter_method_requires_base_id(method) and not str(params.get("base_id") or "").strip(): raise ValueError(f"adapter method {method} requires params.base_id") + +def agent_technical_selector_fields(value: Any) -> list[str]: + """Reject storage coordinates even when a caller nests them in JSON.""" + found: set[str] = set() + if isinstance(value, dict): + for key, nested in value.items(): + if key in AGENT_FORBIDDEN_TECHNICAL_SELECTOR_FIELDS: + found.add(key) + found.update(agent_technical_selector_fields(nested)) + elif isinstance(value, list): + for nested in value: + found.update(agent_technical_selector_fields(nested)) + return sorted(found) + + +def prepare_agent_adapter_call(method: str, params: dict[str, Any]) -> dict[str, Any]: + """Keep the agent on public metadata selectors rather than SQL routes.""" + prepared = dict(params) + diagnostic_allowed = str(os.environ.get("ONEC_AGENT_ALLOW_DIAGNOSTIC") or "").strip().casefold() in {"1", "true", "yes", "on"} + technical = agent_technical_selector_fields(prepared) + if technical and not diagnostic_allowed: + raise ValueError( + "agent adapter calls require public names/selectors; forbidden technical fields: " + ", ".join(technical) + ) + if method.startswith(AGENT_CONFIGURATION_METHOD_PREFIXES): + prepared.setdefault("configuration_view", "effective_working") + prepared.setdefault("source_state", "working") + return prepared + def call_adapter(method: str, params: dict[str, Any] | None, *, base_url: str | None = None) -> dict[str, Any]: - params = params or {} + """Call the adapter through its public MCP boundary, never its SQL REST surface.""" + params = prepare_agent_adapter_call(method, params or {}) validate_adapter_call(method, params) - adapter_url = normalize_base_url(base_url or os.environ.get("ONEC_ADAPTER_URL", "http://docker-gpu.cin.su:8011")) - headers = {"Content-Type": "application/json"} - token = os.environ.get("ONEC_ADAPTER_TOKEN", "").strip() - if token: - headers["Authorization"] = f"Bearer {token}" - request = Request( - f"{adapter_url}/rpc", - data=json.dumps({"method": method, "payload": params}, ensure_ascii=False).encode("utf-8"), + mcp_url = normalize_base_url(base_url or os.environ.get("ONEC_MCP_URL", "http://docker.cin.su:8021")) + headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + initialize = { + "jsonrpc": "2.0", + "id": f"onec-agent-init-{uuid.uuid4().hex}", + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "onec-agent", "version": "1"}, + }, + } + init_request = Request( + f"{mcp_url}/mcp", + data=json.dumps(initialize, ensure_ascii=False).encode("utf-8"), headers=headers, method="POST", ) + try: + with urlopen(init_request, timeout=30) as response: + init_raw = json.loads(response.read().decode("utf-8")) + session_id = response.headers.get("Mcp-Session-Id") + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise ValueError(f"MCP initialize returned HTTP {exc.code}: {body}") from exc + if not isinstance(init_raw, dict) or not isinstance(init_raw.get("result"), dict): + raise ValueError("MCP initialize response is not JSON-RPC success") + call_headers = dict(headers) + if session_id: + call_headers["Mcp-Session-Id"] = session_id + call = { + "jsonrpc": "2.0", + "id": f"onec-agent-call-{uuid.uuid4().hex}", + "method": "tools/call", + "params": {"name": "onec_request", "arguments": {"method": method, "payload": params}}, + } + request = Request( + f"{mcp_url}/mcp", + data=json.dumps(call, ensure_ascii=False).encode("utf-8"), + headers=call_headers, + method="POST", + ) try: with urlopen(request, timeout=120) as response: raw = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") - raise ValueError(f"adapter returned HTTP {exc.code}: {body}") from exc - if not isinstance(raw, dict): - raise ValueError("adapter response is not JSON") - return raw + raise ValueError(f"MCP tool call returned HTTP {exc.code}: {body}") from exc + result = raw.get("result") if isinstance(raw, dict) and isinstance(raw.get("result"), dict) else None + content = result.get("content") if isinstance(result, dict) and isinstance(result.get("content"), list) else [] + text = content[0].get("text") if content and isinstance(content[0], dict) else None + if not isinstance(text, str): + raise ValueError("MCP tool response has no JSON text content") + try: + decoded = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError("MCP tool response text is not JSON") from exc + if not isinstance(decoded, dict): + raise ValueError("MCP tool response payload is not an object") + return decoded class AgentHandler(BaseHTTPRequestHandler): diff --git a/plugins/1c/bridge/README.md b/plugins/1c/bridge/README.md new file mode 100644 index 0000000..4d6e451 --- /dev/null +++ b/plugins/1c/bridge/README.md @@ -0,0 +1,32 @@ +# Development oracle for additional requisites + +`additional_attributes_readonly.bsl` is server-side BSL intended only for the +isolated `upo_test` development configuration. It is an oracle for comparing +the platform result with the SQL adapter result; it is never a dependency of +the adapter, including in `upo_test`. It does not write application data or +configuration metadata. `additional_attributes_http_handler.bsl` is an +optional URL-template handler for manual development checks. + +The service must expose two authenticated read-only operations: + +- `additional_attributes.find` → `НайтиДополнительныеРеквизиты`; +- `additional_attributes.storage.resolve` → `МаршрутЗначенийДополнительногоРеквизита`. + +The HTTP wrapper must accept/return JSON and restrict calls to the test +network. It must pass only `query`, `include_deleted`, a property UUID, and +the public owner selector. Do not accept arbitrary BSL or query text. + +Use the result only to create SQL adapter fixtures and verify its semantic +mapping. The adapter itself must not call this endpoint, read its URL, or +depend on a 1C runtime connection. A missing physical source in `upo_test` +must therefore produce explicit SQL diagnostics, never a platform fallback. + +Before publishing: + +1. Add both modules to a test-only extension or HTTP service in Designer and + bind `ОбработатьRPC` to `POST /rpc`. +2. Restrict the service to read-only calls and test-network access. +3. Create or identify a non-deleted test property `Ответственное направление`. +4. Verify that the service returns its UUID, formula identifier, property set, + and value type; then verify the storage-route response for + `Справочник.СтруктурныеЕдиницы`. diff --git a/plugins/1c/bridge/additional_attributes_http_handler.bsl b/plugins/1c/bridge/additional_attributes_http_handler.bsl new file mode 100644 index 0000000..92daf71 --- /dev/null +++ b/plugins/1c/bridge/additional_attributes_http_handler.bsl @@ -0,0 +1,60 @@ +// Обработчик URL-шаблона HTTP-сервиса, например POST /runtime-bridge/rpc. +// Требует общий серверный модуль ДополнительныеРеквизитыReadOnly +// (additional_attributes_readonly.bsl) в составе тестового расширения. + +Функция ОбработатьRPC(Запрос) Экспорт + Попытка + ДанныеЗапроса = ПрочитатьJSONИзСтроки(Запрос.ПолучитьТелоКакСтроку()); + Метод = ДанныеЗапроса.method; + Параметры = ДанныеЗапроса.payload; + Если Метод = "additional_attributes.find" Тогда + ТекстПоиска = ПолучитьПараметр(Параметры, "query", ""); + ВключатьУдаленные = ПолучитьПараметр(Параметры, "include_deleted", Ложь); + Результат = Новый Структура("status,properties", "found", ДополнительныеРеквизитыReadOnly.НайтиДополнительныеРеквизиты(ТекстПоиска, ВключатьУдаленные)); + Если Результат.properties.Количество() = 0 Тогда + Результат.status = "not_found"; + КонецЕсли; + ИначеЕсли Метод = "additional_attributes.storage.resolve" Тогда + СсылкаСвойства = ПланыВидовХарактеристик.ДополнительныеРеквизитыИСведения.ПолучитьСсылку(Новый УникальныйИдентификатор(Параметры.property_ref)); + Маршрут = ДополнительныеРеквизитыReadOnly.МаршрутЗначенийДополнительногоРеквизита(СсылкаСвойства, Параметры.owner_ref); + Результат = Новый Структура("status,property_ref,owner_ref,storage,scd_join", "confirmed", Параметры.property_ref, Параметры.owner_ref, + Новый Структура("source_ref,source_kind,fields", Маршрут.source, "TabularSection", Новый Структура("object,property,value", Маршрут.object_field, Маршрут.property_field, Маршрут.value_field)), + Новый Структура("source,alias,condition,value_expression,parameters", Маршрут.source, "ДополнительныеРеквизиты", Маршрут.query_join, "ДополнительныеРеквизиты.Значение", Новый Структура("Свойство", Маршрут.parameter))); + Иначе + Возврат ОтветJSON(405, Новый Структура("status,error", "invalid_argument", "Unsupported read-only bridge method.")); + КонецЕсли; + Возврат ОтветJSON(200, Результат); + Исключение + // Не передаем внутренний стек и сведения о подключении. + Возврат ОтветJSON(400, Новый Структура("status,error", "error", "Invalid read-only bridge request.")); + КонецПопытки; +КонецФункции + +Функция ПолучитьПараметр(СтруктураПараметров, Имя, ЗначениеПоУмолчанию) Экспорт + Значение = ЗначениеПоУмолчанию; + Если СтруктураПараметров.Свойство(Имя, Значение) Тогда + Возврат Значение; + КонецЕсли; + Возврат ЗначениеПоУмолчанию; +КонецФункции + +Функция ПрочитатьJSONИзСтроки(ТекстJSON) Экспорт + ЧтениеJSON = Новый ЧтениеJSON; + ЧтениеJSON.УстановитьСтроку(ТекстJSON); + Попытка + Возврат ПрочитатьJSON(ЧтениеJSON); + Наконец + ЧтениеJSON.Закрыть(); + КонецПопытки; +КонецФункции + +Функция ОтветJSON(КодСостояния, Данные) Экспорт + ЗаписьJSON = Новый ЗаписьJSON; + ЗаписьJSON.УстановитьСтроку(); + ЗаписатьJSON(ЗаписьJSON, Данные); + ТекстJSON = ЗаписьJSON.Закрыть(); + Ответ = Новый HTTPСервисОтвет(КодСостояния); + Ответ.УстановитьТелоИзСтроки(ТекстJSON, КодировкаТекста.UTF8, ИспользованиеByteOrderMark.НеИспользовать); + Ответ.Заголовки.Вставить("Content-Type", "application/json; charset=utf-8"); + Возврат Ответ; +КонецФункции diff --git a/plugins/1c/bridge/additional_attributes_readonly.bsl b/plugins/1c/bridge/additional_attributes_readonly.bsl new file mode 100644 index 0000000..ec67c36 --- /dev/null +++ b/plugins/1c/bridge/additional_attributes_readonly.bsl @@ -0,0 +1,85 @@ +// Общий модуль серверного HTTP-сервиса. Все экспортные методы только читают данные. +// Модуль предназначен для публикации в тестовой конфигурации, а не для выполнения +// из SQL-адаптера. Аутентификацию и разбор HTTP-запроса реализует модуль сервиса. + +Функция НайтиДополнительныеРеквизиты(ТекстПоиска = "", ВключатьПомеченныеНаУдаление = Ложь) Экспорт + + Результат = Новый Массив; + Выборка = ПланыВидовХарактеристик.ДополнительныеРеквизитыИСведения.Выбрать(); + Пока Выборка.Следующий() Цикл + Если Не ВключатьПомеченныеНаУдаление И Выборка.ПометкаУдаления Тогда + Продолжить; + КонецЕсли; + Если ЗначениеЗаполнено(ТекстПоиска) + И СтрНайти(НРег(Выборка.Наименование), НРег(ТекстПоиска)) = 0 Тогда + Продолжить; + КонецЕсли; + + ОбъектСвойства = Выборка.ПолучитьОбъект(); + СтрокаСвойства = Новый Структура; + СтрокаСвойства.Вставить("ref", Строка(Выборка.Ссылка.УникальныйИдентификатор())); + СтрокаСвойства.Вставить("description", Выборка.Наименование); + СтрокаСвойства.Вставить("marked_for_deletion", Выборка.ПометкаУдаления); + ДобавитьСвойствоЕслиЕсть(СтрокаСвойства, ОбъектСвойства, "Имя", "name"); + ДобавитьСвойствоЕслиЕсть(СтрокаСвойства, ОбъектСвойства, "ИдентификаторДляФормул", "identifier_for_formula"); + ДобавитьСвойствоЕслиЕсть(СтрокаСвойства, ОбъектСвойства, "НаборСвойств", "property_set"); + ДобавитьСвойствоЕслиЕсть(СтрокаСвойства, ОбъектСвойства, "ТипЗначения", "value_type"); + // Отдельно фиксируем наличие реквизита в объекте ПВХ. Это позволяет + // SQL-разработке отличить пустое значение от отсутствующей семантики. + СтрокаСвойства.Вставить("semantic_fields", Новый Структура( + "name,identifier_for_formula,property_set,value_type", + СтрокаСвойства.Свойство("name"), + СтрокаСвойства.Свойство("identifier_for_formula"), + СтрокаСвойства.Свойство("property_set"), + СтрокаСвойства.Свойство("value_type"))); + Результат.Добавить(СтрокаСвойства); + КонецЦикла; + + Возврат Результат; +КонецФункции + +Функция МаршрутЗначенийДополнительногоРеквизита(Свойство, ВладелецМетаданных) Экспорт + + Если ТипЗнч(Свойство) <> Тип("ПланВидовХарактеристикСсылка.ДополнительныеРеквизитыИСведения") Тогда + ВызватьИсключение "Свойство должно быть ссылкой ПВХ ДополнительныеРеквизитыИСведения."; + КонецЕсли; + Если ВладелецМетаданных <> "Справочник.СтруктурныеЕдиницы" Тогда + ВызватьИсключение "Маршрут подтвержден только для Справочник.СтруктурныеЕдиницы."; + КонецЕсли; + + // Текст предназначен для СКД и не исполняется сервисом. В этой + // конфигурации значения подтверждённо находятся в табличной части владельца. + Возврат Новый Структура( + "source,object_field,property_field,value_field,query_join,parameter", + "Справочник.СтруктурныеЕдиницы.ДополнительныеРеквизиты", + "Ссылка", + "Свойство", + "Значение", + "ЛЕВОЕ СОЕДИНЕНИЕ Справочник.СтруктурныеЕдиницы.ДополнительныеРеквизиты КАК ДополнительныеРеквизиты " + + "ПО ДополнительныеРеквизиты.Ссылка = СтруктурныеЕдиницы.Ссылка " + + "И ДополнительныеРеквизиты.Свойство = &Свойство", + Строка(Свойство.УникальныйИдентификатор()) + ); +КонецФункции + +Процедура ДобавитьСвойствоЕслиЕсть(Приемник, Источник, ИмяСвойства, ИмяПоля) Экспорт + ЗначениеСвойства = Неопределено; + Если Источник.Свойство(ИмяСвойства, ЗначениеСвойства) Тогда + Приемник.Вставить(ИмяПоля, ПредставлениеДляJSON(ЗначениеСвойства)); + КонецЕсли; +КонецПроцедуры + +Функция ПредставлениеДляJSON(ЗначениеСвойства) Экспорт + Если ЗначениеСвойства = Неопределено Тогда + Возврат Неопределено; + КонецЕсли; + Если ТипЗнч(ЗначениеСвойства) = Тип("Структура") Или ТипЗнч(ЗначениеСвойства) = Тип("Массив") Тогда + Возврат ЗначениеСвойства; + КонецЕсли; + Попытка + УникальныйИдентификатор = ЗначениеСвойства.УникальныйИдентификатор(); + Возврат Новый Структура("ref,presentation", Строка(УникальныйИдентификатор), Строка(ЗначениеСвойства)); + Исключение + КонецПопытки; + Возврат Строка(ЗначениеСвойства); +КонецФункции diff --git a/plugins/1c/connector/.env.example b/plugins/1c/connector/.env.example index 9f07847..fb6ef17 100644 --- a/plugins/1c/connector/.env.example +++ b/plugins/1c/connector/.env.example @@ -33,8 +33,15 @@ ONEC_ADAPTER_CACHE_DB=/data/adapter-cache.sqlite ONEC_ADAPTER_STATE_DB=/data/adapter-cache.sqlite # Legacy JSON job store is read once for migration only. ONEC_ADAPTER_JOB_STORE=/data/adapter-jobs.json +# Repository requests, confirmations, sessions, and audit are stored in +# ONEC_ADAPTER_STATE_DB. This legacy JSON is imported once and never updated. +ONEC_REPOSITORY_STATE_FILE=/data/onec-repository-locks.json ONEC_ADAPTER_BACKUP_DIR=/data/adapter-apply-backups ONEC_ADAPTER_WRITE_LEARNING_DIR=/data/adapter-write-learning +# Activation requests/events are stored in ONEC_ADAPTER_STATE_DB. +# Legacy JSON is read once for migration only and is never updated afterwards. +ONEC_CONFIGURATION_ACTIVATION_STATE_FILE=/data/onec-configuration-activation-requests.json +ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS=1800 ONEC_ADAPTER_JOB_TIMEOUT_SECONDS=240 ONEC_ADAPTER_FULL_TIMEOUT_SECONDS=600 ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS=180 @@ -43,3 +50,7 @@ ONEC_ADAPTER_JOB_PROCESS_ISOLATION=true # Optional POSIX child-process limits; 0 keeps the platform/container limit. ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB=0 ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS=0 +# Stack traces are hidden from REST/MCP clients unless these test/debug flags +# are explicitly enabled. +ONEC_ADAPTER_DEBUG_DIAGNOSTICS=false +ONEC_MCP_DEBUG_DIAGNOSTICS=false diff --git a/plugins/1c/connector/Dockerfile b/plugins/1c/connector/Dockerfile index 99f5f96..332e981 100644 --- a/plugins/1c/connector/Dockerfile +++ b/plugins/1c/connector/Dockerfile @@ -3,7 +3,9 @@ FROM python:3.12-slim WORKDIR /app RUN pip install --no-cache-dir pymssql==2.3.2 COPY connector/adapter_1c_server.py /app/adapter_1c_server.py +COPY connector/analyze_audit.py /app/analyze_audit.py COPY connector/repository_control.py /app/repository_control.py +COPY connector/write /app/write COPY connector/admin /app/admin COPY parser /app/parser diff --git a/plugins/1c/connector/README.md b/plugins/1c/connector/README.md index 71f8729..d8ce6a3 100644 --- a/plugins/1c/connector/README.md +++ b/plugins/1c/connector/README.md @@ -6,8 +6,9 @@ The connector is read-first and optimized for an operational coding loop where f Preferred live architecture: -- read-only SQL connector for fast diagnostics and data samples; -- lightweight 1C agent for metadata, forms, commands, and BSL modules; +- SQL-only connector for diagnostics, metadata decoding, and controlled + saved-state work in an explicitly authorised test base; +- a human-operated Configurator for viewing and applying pending changes; - cached metadata/module snapshots with freshness checks; - change proposals as reviewable artifacts, not direct production writes. @@ -17,7 +18,12 @@ The connector is responsible for: - BSL module search/read; - read-only query validation and execution; - metadata/module snapshots; -- change proposals without direct apply. +- change proposals and, only where a reverse codec is activation-proven, + controlled `ConfigSave`/`ConfigCASSave` writes with rollback evidence. + +The adapter never writes `Config`, `ConfigCAS`, or application data directly. +It does not automate Configurator and must not invent unknown 1C structures. +The protocol evidence base is [docs/1c-sql-protocol](../../../docs/1c-sql-protocol/README.md). Contracts: @@ -122,6 +128,57 @@ adapter-owned lock session is supplied. Structural add/delete/rename plans are kept blocked for confirmation because parent and reference objects can also be required. +## Configuration activation debug workflow + +Activation is a separate boundary from saved-state writes and repository +coordination. The current workflow is intentionally debug-only: + +1. `configuration.activation.status`; +2. `configuration.activation.plan`; +3. `configuration.activation.request`; +4. forward the returned request id to `configuration.activation.execute` with + `mode=debug` and `confirm_activation=true`; +5. inspect or cancel the request through + `configuration.activation.request.status`, + `configuration.activation.request.cancel`, and + `configuration.activation.audit`. + +The request is bound to a live-SQL fingerprint and is rejected when pending +files change or the request expires. Requests and events are stored in the +adapter-local SQLite selected by `ONEC_ADAPTER_STATE_DB`; they contain no +payload bytes or credentials. `ONEC_CONFIGURATION_ACTIVATION_STATE_FILE` is a +one-time legacy JSON import source only. `configuration.activation.capabilities` +reports runner readiness without returning paths, URLs, selectors, users, +passwords, or tokens. +`configuration.activation.bridge.probe` can then check the local runner or the +authenticated HTTP runner endpoint `/configuration/activation/debug`. The +probe verifies only Designer-file availability and infobase-selector presence; +it never starts a process. +Pass `bridge_debug=true` to `configuration.activation.execute` when the runner +must also acknowledge the exact request id and live-SQL fingerprint. The runner +returns an opaque SHA-256 debug receipt; mismatched or missing receipts block +the request, while a valid receipt adds a `bridge_debug_accepted` audit event. +After a manual F7, call `configuration.activation.verify` with the same request +id. It reports `not_activated`, `changed_since_request`, or +`verified_up_to_date` from a fresh SQL comparison. The last status proves +saved/active alignment, not the historical fact that Designer performed the +activation. + +Real Designer execution remains disabled. `/UpdateDBCfg` is recorded only as +the documented future base-configuration operation. Extension activation stays +manual until a separately verified platform command and post-activation check +are implemented. + +Activation request mutations use SQLite `BEGIN IMMEDIATE` transactions, so +concurrent adapter processes cannot overwrite each other's request/event +updates. Saved-state backup retention is explicit: +`storage.saved_state.backups.prune` defaults to a dry run, is scoped by +`base_id`, preserves the newest requested count, and requires +`confirm_delete=true` before deleting adapter-local backup files. Backups +referenced by `metadata.write.history` are always protected; when write-history +availability cannot be verified, affected backup files are protected +fail-closed. + ## Docker Run Create a local `.env` from `.env.example`, keep real passwords outside git, and @@ -237,6 +294,8 @@ Current live methods: - `metadata.route.resolve` - `metadata.form.decode` - `metadata.object.attributes` +- `metadata.relationship.verify` +- `metadata.relationship.find` - `metadata.object.full` - `metadata.snapshot` - `codec.decode` @@ -287,6 +346,9 @@ Agent-facing code write rule: public path such as `.
.` plus full code text. - `code.write` automatically targets the saved-state layer and reports `write_mode.target=saved_state` with `activation_state=not_activated`. +- Write plans for embedded form-container modules return a ready + `code.write` hint; they do not incorrectly request a nonexistent + `#stream:`. - Use `code.read`/`code.search` with the default working state for current programming-time code; use `state=both` only when an explicit saved vs active comparison is needed. @@ -296,6 +358,10 @@ without physical SQL/storage traces by default. `metadata.object.decode` also returns a 1C-facing decoded object profile by default; pass `include_storage=true` only when adapter diagnostics need the underlying decoded payload metadata, record containers, or DBNames/storage routes. +Exact extension objects use the same public `kind` + `name`/`ref` selectors as +base objects. `metadata.object.modules` includes owned form modules and returns +qualified names such as +`test2.Форма.t_Форма.Модуль формы`; extension GUIDs and CAS keys remain internal. `metadata.object.properties` is the unified property endpoint for every 1C metadata kind. It selects a kind-specific SQL decoder for `Configuration`, @@ -348,6 +414,32 @@ inspection for user-facing answers. The object can be selected by `guid`, by `kind` + `name`, or by 1-based `ordinal` within `metadata.objects.list` for that kind. +For a safe answer to "are these objects linked?", do not infer a link from a +similar field name, BSL mention, or a runtime value. Use +`metadata.relationship.verify` with an exact source `member` and optional +`target_ref`. It returns `confirmed` only when that member's declared 1C type +explicitly names the target object; otherwise it returns `not_confirmed` or an +explicitly ambiguous result. To discover a direct typed field without knowing +its name, call `metadata.relationship.find` with public refs only: + +```json +{ + "method": "metadata.relationship.find", + "payload": { + "base_id": "upo_test", + "ref": "Document.СписаниеЗапасов", + "target_ref": "Document.РасходнаяНакладная", + "direction": "either", + "execution_mode": "job" + } +} +``` + +`direction=either` checks both objects for explicitly declared references and +returns the direction of every confirmed edge. A `not_found` result means that +no direct declared metadata reference was found; it does not prove that an +indirect BSL, query, form, or business-process relationship is absent. + `metadata.object.full` is the preferred high-level method for agent answers like "show everything about this document". It combines the live object card, semantic sections, decoded forms, BSL module profiles, and counts in one @@ -488,7 +580,33 @@ unless `include_storage=true`. `code.search` is the agent-facing wrapper over module search. Its items include `read_selector.method: "code.read"` and preserve `module_ref` when that is the -best available safe handle. `code.read` can consume that selector directly. +best available safe handle. `code.read` can consume that selector directly; +the selector pins the configuration view that produced the hit. A storage +stream whose Configurator-tree role is not independently decoded is returned +as `bsl_module` with `role_status=unconfirmed` and must not be treated as a +command, manager, or a tree path. + +`metadata.object.commands` resolves an `extension` name to the active +extension internally before it reads the selected object. A caller provides +only the public object and extension selectors; it must not replace them with +a base-configuration route or infer a command from a BSL stream suffix. A +successful empty command list is the only evidence currently returned for “no +decoded commands”; an unresolved object route is reported separately. +For object-owned extension forms, `modules.search` and `code.search` resolve +the form module from the public owner reference. In the default +`state=working` view they inspect the saved counterpart first and fall back to +the active module only when needed; `state=active` never returns saved-only +text. Saved matches carry `activation_state=saved_state` and +`current_state.activation_state=not_activated`. +For an active extension form selector, `code.read state=both` resolves the +saved form by logical owner/form identity, even when active and saved CAS file +names differ, and reports live text SHA1 comparison evidence. + +`metadata.resolve_overrides` uses the same name-first form ownership and +saved-first working-state rules. A public selector such as `Catalog.test2` +therefore resolves routines located in forms owned by that extension object; +the returned chain identifies the form and activation state without exposing +the object's physical SQL route. `metadata.definition.find` accepts public object references such as `Обработка.` or `Document.` in `query` and the common object @@ -553,3 +671,49 @@ be proven, the adapter must re-read live SQL or return an explicit stale-cache error. Operational runbook: `docs/runbooks/1c-operational-coding.md`. + +## Development audit telemetry + +Every REST `/rpc` call produces a privacy-safe JSONL event in +`/data/adapter-audit.jsonl`. It contains the UTC time, correlation id, public +method and selector summary, result status/error, duration, public route and +resolver timings/counts (when a write route is involved), and exception type +when the request itself fails. A `public_write_route_unresolved` event retains +the safe resolver status/error/candidate count so it can be diagnosed without +asking a caller for a module handle. It deliberately excludes BSL text, SQL +payloads, physical file names, stream indexes, credentials, and SQL connection +details. The MCP proxy forwards its generated +request id in `X-Request-ID`, so an agent response can be correlated with the +REST record. The log is shared by all configured +`base_id` values so cross-base failures and slow calls can be compared. + +For development, the default retention is deliberately generous: 50 MiB per +file and ten retained files. Configure `ONEC_ADAPTER_AUDIT_MAX_BYTES` and +`ONEC_ADAPTER_AUDIT_KEEP_FILES` to change it. Rotation is best-effort and can +never fail an adapter request. A caller may supply an `X-Request-ID` header to +correlate a client event with the REST record. + +The `adapter-1c-audit` Compose service writes an aggregate report every 15 +minutes to `/data/adapter-audit-reports/latest.json`; set +`ONEC_ADAPTER_AUDIT_INTERVAL_SECONDS` to alter the interval. It reports base +distribution, failures, slow operations, malformed rows, and recent failures. +For an immediate manual report, run `python scripts/analyze_1c_adapter_audit.py` +against a copied log or `python /app/analyze_audit.py` inside the REST image. +The MCP proxy has its own persistent `/data/mcp-audit.jsonl` and periodic +summary: it records failures that happen before a request reaches REST. + +For an extension-wide `code.search` without a concrete object selector, +`timeout_seconds` is a total search budget. If owner-route discovery consumes +that budget, the adapter returns `status=partial` with +`diagnostics.code=time_budget_exhausted`; it does not continue serial owner +probes in the background. Narrow routine work with `ref` or `kind`/`name`. + +REST deployments use a five-minute Docker stop grace period. On `SIGTERM` the +adapter stops accepting new work and waits for already-running request threads, +including verified saved-state writes, to complete. Do not deploy the REST +service while an operator is intentionally running a production-base write; +the deployment prevents a half-response, but the client should still retry only +after it receives a structured result. +The deployment script also waits for `health.runtime.active_rpc_count=0` before +recreating REST. `-SkipDrainCheck` is an emergency-only override and must not +be used while a write is in progress. diff --git a/plugins/1c/connector/adapter_1c_server.py b/plugins/1c/connector/adapter_1c_server.py index e5a63fe..250c2eb 100644 --- a/plugins/1c/connector/adapter_1c_server.py +++ b/plugins/1c/connector/adapter_1c_server.py @@ -1,8 +1,13 @@ from __future__ import annotations +# Deployment rule: adapter-1c-mcp is deployed and integration-tested on +# docker.cin.su. Do not substitute test-docker unless the task explicitly +# requests the staging environment. + import argparse import base64 import copy +from collections import Counter import hmac import concurrent.futures import csv @@ -15,6 +20,7 @@ import multiprocessing import os import queue import re +import signal import sqlite3 import sys import threading @@ -26,6 +32,7 @@ import urllib.request import uuid import xml.etree.ElementTree as ET import zlib +from contextlib import contextmanager from datetime import date, datetime, timezone from decimal import Decimal from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -37,6 +44,9 @@ if str(CONNECTOR_ROOT) not in sys.path: sys.path.insert(0, str(CONNECTOR_ROOT)) import repository_control +from write.context import AdapterWriteContext +from write.handlers.scheduled_job import execute as execute_scheduled_job_handler +from write.registry import registered_handlers, select_write_handler SCHEMA = "onec_adapter_rest.v1" @@ -64,16 +74,27 @@ SENSITIVE_RESULT_FIELD_RE = re.compile( ADAPTER_JOBS: dict[str, dict[str, Any]] = {} ADAPTER_JOB_LOCK = threading.Lock() ADAPTER_JOB_TTL_SECONDS = 1800 +ADAPTER_STATE_SCHEMA_VERSION = 2 ADAPTER_JOB_STORE_LOADED = False ADAPTER_INSTANCE_ID = uuid.uuid4().hex +# Not exposed through health, jobs, or audit records. It authenticates only +# process-local handoff between the authorization, preparation and writer +# phases of one request. +ADAPTER_WRITE_AUTHORIZATION_SECRET = os.urandom(32) ADAPTER_JOB_EVENT_SINK: Any = None +ADAPTER_JOB_CONTEXT = threading.local() +CONFIGURATION_ACTIVATION_STATE_LOCK = threading.RLock() BASE_ROOT_METADATA_CACHE: dict[tuple[str, str], dict[str, Any]] = {} BASE_ROOT_METADATA_CACHE_LOCK = threading.Lock() BASE_ROOT_METADATA_CACHE_TTL_SECONDS = 300 DATA_SCHEMA_CACHE: dict[str, dict[str, Any]] = {} DATA_SCHEMA_CACHE_LOCK = threading.Lock() DATA_SCHEMA_CACHE_TTL_SECONDS = 300 +CHART_VALUE_TYPE_CACHE: dict[tuple[str, str], dict[str, Any]] = {} +CHART_VALUE_TYPE_CACHE_LOCK = threading.Lock() +CHART_VALUE_TYPE_CACHE_TTL_SECONDS = 1800 MOXEL_TEMPLATE_ARTIFACT_KIND = "template_part_moxel_v11" +SCD_TEMPLATE_ARTIFACT_KIND = "dcs_template_inspect_v1" TEMPLATE_CONTENT_DEFAULT_MAX_BYTES = 262144 TEMPLATE_CONTENT_MAX_BYTES = 1048576 FULL_METHOD_SECTIONS = {"card", "semantic", "forms", "templates", "commands", "modules", "parts_summary"} @@ -709,7 +730,8 @@ METHODS = [ {"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": "repository.layer.connection.plan", "transport": "POST /rpc", "description": "Read-only plan for configuring repository policy for base or one extension by name or GUID. It never changes adapter configuration or a 1C database."}, + {"name": "repository.layer.connection.set", "transport": "POST /rpc", "description": "Persist the explicit repository connection state for base or one extension in adapter configuration only. Accepts extension name or GUID, requires confirm_repository_connection_change=true, and never writes a 1C SQL database."}, {"name": "metadata.support.decode", "transport": "POST /rpc", "description": "Decode ParentConfigurations support rules from live SQL. The base source is resolved through Config/root and an extension source through its complete ConfigCAS manifest. Repository capture is evaluated separately."}, {"name": "repository.sql_state.snapshot", "transport": "POST /rpc", "description": "Read-only hashes of selected live SQL configuration-state payloads for controlled before/after repository-state experiments. Requires diagnostic=true and never infers a native lock."}, {"name": "repository.sql_state.diff", "transport": "POST /rpc", "description": "Compare two repository.sql_state.snapshot responses and report changed records and parts. Requires diagnostic=true and never infers a native lock."}, @@ -737,10 +759,19 @@ METHODS = [ {"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."}, + {"name": "metadata.object.components", "transport": "POST /rpc", "description": "Read-only confirmed component graph for one metadata object. Returns only modules, forms, templates, and SCD nodes confirmed by the existing live-SQL decoders, with public child selectors and explicit unresolved areas. It never infers absent children from an object kind."}, + {"name": "metadata.object.modules", "transport": "POST /rpc", "description": "1C-facing BSL module list for a metadata object, including owned form modules. Exact extension objects are resolved from public kind/name or ref through a live-validated route cache. include_storage must be a JSON boolean true/false, string values are invalid. Physical module ids are hidden unless include_storage=true."}, {"name": "metadata.object.related", "transport": "POST /rpc", "description": "1C-facing related metadata objects such as forms and templates. Physical record paths are hidden unless include_storage=true."}, + {"name": "scd.inspect", "transport": "POST /rpc", "description": "Read-only inspection entry point for a report Data Composition Schema. Resolves a base report from Config or an extension report from ConfigCAS by public names, and returns partial evidence instead of inventing undecoded parameters, datasets, resources, or variants."}, + {"name": "scd.prepare", "transport": "POST /rpc", "description": "Prepare the complete SQL saved-state file set required for one report SCD. For base reports it includes both report files and the separately stored Template payload group, while keeping SQL file identifiers internal."}, + {"name": "scd.prepare.rollback", "transport": "POST /rpc", "description": "Remove exactly the rows inserted by one SCD preparation receipt after hash precondition checks. This is intended for controlled test cleanup and never touches active Config/ConfigCAS."}, + {"name": "scd.compare", "transport": "POST /rpc", "description": "Read-only semantic comparison of active and prepared saved-state SCD XML: Config ↔ ConfigSave for base reports and ConfigCAS ↔ ConfigCASSave for extensions. Compares named parameters, datasets, fields, expressions, totals, and variants rather than storage hashes."}, + {"name": "scd.agent_report", "transport": "POST /rpc", "description": "Compact SQL-evidenced SCD report for coding agents: verified sources/fields, static query signals, unresolved items, and safe next calls. Pass field to include dcs.field.lineage for one output field. Never executes the report or applies changes."}, + {"name": "dcs.field.lineage", "transport": "POST /rpc", "description": "Trace one SCD field to its query expression and direct 1C metadata sources using SQL-decoded SCD XML. Static evidence only; never executes a 1C query."}, + {"name": "scd.patch", "transport": "POST /rpc", "description": "Build a reviewed, byte-preserving saved-state SQL proposal for one SCD dataset query or calculated/resource expression. Resolves report/schema by public names; uses ConfigSave for a base report and ConfigCASSave for an extension; writes no SQL unless explicit apply gates are supplied."}, {"name": "metadata.object.forms", "transport": "POST /rpc", "description": "1C-facing forms for a metadata object with form part roles. include_storage must be a JSON boolean true/false, string values are invalid. Physical Config part keys are hidden unless include_storage=true."}, - {"name": "metadata.object.form.details", "transport": "POST /rpc", "description": "Decode object forms into public form properties: elements, attributes, commands, events, links, and decoded form parameters. Optional element/element_path/element_id focuses the returned elements list on one form element. include_parameters controls decoded parameter lists; max_parameters limits parameters per form node."}, + {"name": "metadata.object.form.details", "transport": "POST /rpc", "description": "Decode object forms into public form properties: elements, attributes, commands, events, links, and decoded form parameters. Optional element/element_path/element_id focuses the returned elements list on one form element. include_parameters controls decoded parameter lists; max_parameters limits parameters per form node. For a heavy form pass execution_mode=job and poll adapter.job.get."}, + {"name": "form.field.lineage", "transport": "POST /rpc", "description": "Object-scoped static lineage for a form field: resolves one owner form then returns matching elements, attributes, data paths, expressions and handlers. match_mode=exact is strict; semantic finds data controls whose logical name contains the requested caption while excluding command controls. analysis_profile=fast returns current data-path proof without expensive calculation/call-chain analysis; full keeps all static evidence. For a heavy form pass execution_mode=job and poll adapter.job.get. Never performs a global code search."}, {"name": "metadata.object.templates", "transport": "POST /rpc", "description": "1C-facing templates/makets for a metadata object with decoded content roles and public properties. evidence_mode controls undecoded payload evidence: none, summary, full, raw. include_storage must be a JSON boolean true/false."}, {"name": "metadata.object.template.details", "transport": "POST /rpc", "description": "Detailed public template/maket information: maket name, format, features, safe preview status, and undecoded evidence. evidence_mode=full/raw returns broader payload evidence; raw offsets require include_storage=true."}, {"name": "templates.read", "transport": "POST /rpc", "description": "Read one or more object templates by owner selector/template name or direct route and return public template structure. MOXCEL templates include decoded dimensions, named-area ranges, text/parameter cells, column widths, cell identifiers, coverage, and capability diagnostics. Pass include_content=true for a bounded read-only export (decoded container as base64 plus extracted HTML/text blocks); max_content_bytes is capped at 1 MiB per item. Use view=summary|structure|full and sections/max_* to keep responses compact."}, @@ -748,18 +779,31 @@ METHODS = [ {"name": "templates.map", "transport": "POST /rpc", "description": "Compact agent-facing template map. Returns summary structure and analysis for MXL/MOXCEL templates without the full decoded payload lists unless sections/max_* request them."}, {"name": "templates.areas.find", "transport": "POST /rpc", "description": "Find a template by extension/object query or direct route and return decoded named areas with coordinates from current template payloads. Use area_query/area_name/area_occurrence for focused report/print-form maket area lookup; include_coverage=false returns a compact coordinate list."}, {"name": "metadata.object.commands", "transport": "POST /rpc", "description": "1C-facing commands for a metadata object. CommandGroup selectors resolve the reverse CommonCommand.Group relation by public names; callers do not pass GUIDs. Physical record paths are hidden unless include_storage=true."}, - {"name": "metadata.definition.find", "transport": "POST /rpc", "description": "Find where a 1C name is defined: top-level metadata objects, object attributes, tabular-section fields, form attributes/elements/events/commands, templates, 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.definition.find", "transport": "POST /rpc", "description": "Find where a 1C name is defined: top-level metadata objects, object attributes, tabular-section fields, form attributes/elements/events/commands, templates, SCD fields, commands, BSL routines, and extension definitions from DBNames-Ext/ConfigCAS. Accepts public refs such as Обработка. or Document., plus ref, kind/name/guid, or object_type/object_name/object_guid selectors. Returns public 1C locations, origin as configuration/extension when known, read selectors, and related_selectors for next adapter calls. A single metadata object match is promoted to top-level object. areas=metadata/extensions can work without an object selector; object/form/module/scd areas require an object selector. Use areas=[\"scd\"] for a field-scoped SQL SCD trace; schema or template narrows it only when that SCD name is proven by decoded metadata. When an object selector or form is passed and areas is omitted, search is scoped to the selected object/form to avoid a full configuration scan. Default is live verification; pass use_cache=true only when a fast local index is acceptable, or refresh_cache=true to rebuild the index after configuration/extension updates. For potentially slow BSL/form/template scans use execution_mode=job and poll adapter.job.get; verified partial matches are retained if its time budget expires. No SQL/storage details unless include_storage=true."}, {"name": "metadata.route.resolve", "transport": "POST /rpc", "description": "Resolve live ConfigCAS/DBNames routes for extension metadata objects or child objects by extension, query, kind, or GUID."}, {"name": "metadata.resolve_overrides", "transport": "POST /rpc", "description": "Build the discovered routine override chain for a target object/routine. Default configuration_view=effective_working means the logical Designer snapshot (base, saved changes, and extension layers); runtime_applied is executable now and compare returns both. The chain records extension action evidence (insert_before/insert_after/replace/replace_with_control); it never presents raw storage tables as the programming API."}, {"name": "metadata.object.special.details", "transport": "POST /rpc", "description": "Backward-compatible kind-specific SQL details for Configuration, Constant, CommonAttribute, SessionParameter, FunctionalOption, FunctionalOptionsParameter, DocumentNumerator, IntegrationService, CommandGroup, Role, ScheduledJob, EventSubscription, WebService, HTTPService, and DocumentJournal. Role details include object rights, RLS conditions, and restriction templates from its SQL .0 payload. Prefer metadata.object.properties. For DocumentJournal pass include_column_types=true to resolve column types."}, - {"name": "metadata.form.decode", "transport": "POST /rpc", "description": "Decode one form into events, elements, commands, attributes, module summary, and decoded form parameters. evidence_mode controls undecoded payload evidence: none, summary, full, raw. Raw offsets require include_storage=true."}, + {"name": "metadata.form.decode", "transport": "POST /rpc", "description": "Decode one form into events, elements, commands, attributes, module summary, and decoded form parameters. view=structure adds a compact static projection; logical parent/child links remain explicitly unresolved until their SQL codec is proven. evidence_mode controls undecoded payload evidence: none, summary, full, raw. Raw offsets require include_storage=true. For a heavy form pass execution_mode=job and poll adapter.job.get."}, {"name": "metadata.form.owner_index.build", "transport": "POST /rpc", "description": "Build/refresh the SQL-backed form owner index for CommonForm and object-owned forms from extension routes or direct form SQL payloads. XML remains analysis/learning only."}, {"name": "metadata.form.write_target.resolve", "transport": "POST /rpc", "description": "Resolve an agent-facing saved-state form write target into table, file_name, profile section, path, current value, candidates, and writable properties."}, {"name": "metadata.form.write_target.verify", "transport": "POST /rpc", "description": "Read-only agent check for a form write target. Verifies whether a saved-state form target is currently writable or whether the adapter would need to prepare ConfigSave/ConfigCASSave first."}, + {"name": "metadata.write_target.resolve", "transport": "POST /rpc", "description": "Resolve one fully-qualified logical write path into an immutable, read-only extension/object/form context. It never prepares or writes saved state and never guesses a physical module container."}, {"name": "metadata.saved_state.prepare", "transport": "POST /rpc", "description": "Prepare an empty saved-state working copy by public 1C object name/ref and semantic layer. Default mode is plan; SQL insert requires allow_sql_saved_state_prepare=true and blocks on target collisions. include_storage=true exposes storage diagnostics."}, + {"name": "metadata.saved_state.ensure", "transport": "POST /rpc", "description": "Idempotently ensure a public object, module, or owned form has a saved-state working copy. It selects ConfigSave or ConfigCASSave from extension provenance, copies only missing active parts, and never overwrites existing saved work. Default mode is plan; SQL insert still requires allow_sql_saved_state_prepare=true."}, + {"name": "metadata.saved_state.ensure.rollback", "transport": "POST /rpc", "description": "Rollback exactly the rows inserted by one metadata.saved_state.ensure receipt after hash precondition checks. It never changes active Config or ConfigCAS and requires allow_sql_saved_state_rollback=true."}, {"name": "metadata.saved_state.status", "transport": "POST /rpc", "description": "Read-only name-first overview of a base_saved_state or extension_saved_state layer. Public mode reports semantic aggregate state; include_storage=true exposes SQL rows/files, hashes, and diff selectors."}, {"name": "metadata.saved_state.diff", "transport": "POST /rpc", "description": "Agent-facing read-only comparison of a saved-state module with its active source. Prefer a 1C object ref or kind/name plus module_ordinal; generated module_ref and table/file_name remain accepted for follow-up tooling. Reports changed/unchanged, needs_prepare, hashes, and compact payload diff."}, {"name": "metadata.saved_state.changes.list", "transport": "POST /rpc", "description": "Read-only name-first list of pending saved-state changes. Filter by semantic layer; public rows resolve 1C object/form/module context and hide SQL coordinates; include_storage=true exposes per-file diff/write diagnostics."}, + {"name": "configuration.activation.status", "transport": "POST /rpc or POST /configuration/activation/status", "description": "Read-only live status of the saved_state → active boundary for base and extension layers. Compares ConfigSave/ConfigCASSave with Config/ConfigCAS through the existing saved-state status reader, never uses the code index as authority, and never starts 1C Designer."}, + {"name": "configuration.activation.plan", "transport": "POST /rpc or POST /configuration/activation/plan", "description": "Build a read-only activation handoff plan from live activation status. Returns the exact review and verification calls plus a manual 1C Designer action when activation is required; execution remains unavailable until a separate confirmed Designer bridge is implemented."}, + {"name": "configuration.activation.request", "transport": "POST /rpc or POST /configuration/activation/request", "description": "Create an expiring adapter-local activation request bound to an exact live-SQL fingerprint of pending saved-state files. The request does not start Designer or alter the infobase."}, + {"name": "configuration.activation.request.status", "transport": "POST /rpc or POST /configuration/activation/request/status", "description": "Read one adapter-local activation request and its expiry/debug acceptance state. No 1C operation is performed."}, + {"name": "configuration.activation.request.cancel", "transport": "POST /rpc or POST /configuration/activation/request/cancel", "description": "Cancel one exact adapter-local activation request with explicit confirm_cancel=true. This never starts Designer and does not modify 1C."}, + {"name": "configuration.activation.audit", "transport": "POST /rpc or POST /configuration/activation/audit", "description": "List bounded adapter-local activation request summaries and lifecycle events for one base_id. Fingerprinted storage evidence is not exposed."}, + {"name": "configuration.activation.capabilities", "transport": "POST /rpc or POST /configuration/activation/capabilities", "description": "Report safe activation workflow and Designer runner readiness without returning credentials, executable paths, URLs, or infobase selectors. Base /UpdateDBCfg remains debug-only; extension execution remains manual until a verified platform command is implemented."}, + {"name": "configuration.activation.bridge.probe", "transport": "POST /rpc or POST /configuration/activation/bridge/probe", "description": "Perform an explicit non-mutating local or HTTP runner readiness probe. It validates Designer availability and infobase selector presence without starting Designer, reading configured passwords, or exposing paths/URLs/selectors."}, + {"name": "configuration.activation.execute", "transport": "POST /rpc or POST /configuration/activation/execute", "description": "Validate an activation request against a fresh live-SQL fingerprint. Only mode=debug is currently accepted; it records a dry-run acceptance after explicit confirmation and never starts Designer or changes the active configuration."}, + {"name": "configuration.activation.verify", "transport": "POST /rpc or POST /configuration/activation/verify", "description": "Verify the post-activation state for one exact activation request through a fresh live-SQL comparison. Marks the request verified_up_to_date only when saved and active layers align; otherwise reports not_activated, changed_since_request, or blocked. Alignment is not presented as proof that Designer performed activation."}, {"name": "metadata.saved_state.forms.search", "transport": "POST /rpc", "description": "Fast name-first saved-state form search by public owner ref, form, element, command, attribute, or text. Public rows expose 1C names and semantic selectors; include_storage=true opts into SQL files, GUIDs, brace paths, markers, and write diagnostics."}, {"name": "metadata.saved_state.modules.search", "transport": "POST /rpc", "description": "Fast name-first saved-state BSL module search over ConfigSave/ConfigCASSave. Accepts public ref or kind/name selectors and resolves SQL routes internally. Public results expose 1C owner/form/module names, previews, and name-first metadata.write.plan targets; include_storage=true additionally exposes module_ref handles and physical write guards."}, {"name": "metadata.form.write_matrix.build", "transport": "POST /rpc", "description": "Build a source-aware matrix of decoded saved-state form scalar properties and safe write-smoke candidates."}, @@ -773,15 +817,17 @@ METHODS = [ {"name": "metadata.write.plan", "transport": "POST /rpc", "description": "Read-only name-first metadata write planner. For forms pass target.kind=form with extension/ref/form and element, command, or attribute plus edits; for modules reuse the public write_plan_target from metadata.saved_state.modules.search. The adapter resolves saved-state SQL handles internally, reports layer/provenance requirements, and never applies changes."}, {"name": "metadata.write.preflight", "transport": "POST /rpc", "description": "Read-only preflight for high-level writes. Combines metadata.write.plan with live saved-state verification and reports ready, needs_prepare, needs_resolution, or blocked before any write."}, {"name": "metadata.write.capabilities", "transport": "POST /rpc", "description": "Agent-facing matrix of what the adapter can read, plan, and write to the saved-state layer. SQL/storage details are hidden unless include_storage=true."}, - {"name": "metadata.write", "transport": "POST /rpc", "description": "High-level 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", "transport": "POST /rpc", "description": "High-level working-configuration write orchestrator. Routes forms, modules, and scheduled-job schedules by public 1C names, builds reviewable proposals, and applies only with explicit write gates. Storage preparation and SQL routing are adapter-internal; module writes support text, old/new, and routine_name/routine_text edits with revision guards."}, {"name": "metadata.write.history", "transport": "POST /rpc", "description": "List recent adapter write operations or fetch one operation_id, including status, routed method, target summary, backup ids, and full result for a specific operation."}, {"name": "metadata.write.rollback", "transport": "POST /rpc", "description": "Rollback a saved-state write by operation_id or backup_id using write history evidence. Requires allow_sql_saved_state_rollback=true."}, - {"name": "code.write", "transport": "POST /rpc", "description": "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."}, + {"name": "code.write", "transport": "POST /rpc", "description": "Plan-first BSL working-configuration write facade. Works with 1C names and code text; without execution_mode it only builds a logical change plan. Apply/apply_and_verify requires explicit write gates. The adapter prepares and verifies its SQL working copy internally. Supports full module text, routine_name/routine_text, and unique old/new fragment replacement."}, {"name": "metadata.write_learning.capture_before", "transport": "POST /rpc", "description": "Capture a saved-state form baseline for write-rule learning. Stores decoded writable targets and storage sha1 without payload hex."}, {"name": "metadata.write_learning.capture_after", "transport": "POST /rpc", "description": "Capture a saved-state form after a manual Designer edit for write-rule learning."}, {"name": "metadata.write_learning.diff", "transport": "POST /rpc", "description": "Compare before/after write-learning captures and return changed writable form properties."}, {"name": "metadata.write_learning.infer_rule", "transport": "POST /rpc", "description": "Infer a metadata.write payload from a write-learning diff."}, - {"name": "metadata.object.attributes", "transport": "POST /metadata/object/attributes or POST /rpc", "description": "High-level 1C object attributes and tabular sections. only must be a string: all, attributes, tabular_sections, dimensions, resources, or register_fields. Default is live verification and then cache update; pass use_cache=true only when a fast local index is acceptable, or refresh_cache=true to force refresh. Use only=attributes or only=tabular_sections for a smaller public response. include_storage and use_cache must be JSON booleans true/false, string values are invalid. SQL/storage traces are hidden unless include_storage=true."}, + {"name": "metadata.object.attributes", "transport": "POST /metadata/object/attributes or POST /rpc", "description": "High-level 1C object attributes and tabular sections. Pass member= to read one member (or a tabular-section column) and receive member_type_evidence: only declared reference targets are marked confirmed; no business relation is inferred. resolve_generated_types=true explicitly resolves generated/defined reference types; it is off by default because it can be expensive for a large object. only must be a string: all, attributes, tabular_sections, dimensions, resources, or register_fields. Default is live verification and then cache update; pass use_cache=true only when a fast local index is acceptable, or refresh_cache=true to force refresh. Use only=attributes or only=tabular_sections for a smaller public response. include_storage, use_cache, and resolve_generated_types must be JSON booleans true/false, string values are invalid. SQL/storage traces are hidden unless include_storage=true."}, + {"name": "metadata.relationship.verify", "transport": "POST /rpc", "description": "Verify a proposed metadata-object relationship through the declared type of one exact attribute or tabular-section column. Requires base_id, source object selector, and member; target_ref is optional. Returns confirmed only for an explicit declared target type and never infers a business relation from similarly named fields, BSL text, or data values."}, + {"name": "metadata.relationship.find", "transport": "POST /rpc", "description": "Find direct attributes, dimensions, resources, or tabular-section columns explicitly declared as references between a source object and target_ref. direction=outgoing (default), incoming, or either; use execution_mode=job for a large object. This is metadata-type evidence only; it does not infer joins from BSL or data."}, {"name": "metadata.object.full", "transport": "POST /metadata/object/full or POST /rpc", "description": "Start a long job for a full high-level 1C object profile: card, semantic sections, decoded forms, templates, commands, module handles, and optional parts evidence. evidence_mode=full/raw automatically includes parts_summary; raw offsets require include_storage=true. Poll adapter.job.get/mcp.job.get."}, {"name": "metadata.snapshot", "transport": "POST /metadata/snapshot or POST /rpc", "description": "1C-facing live metadata summary for a concrete base_id."}, {"name": "metadata.cache.status", "transport": "POST /rpc", "description": "Internal metadata identity cache status for one explicit base_id. No default database is used."}, @@ -816,6 +862,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.backfill", "transport": "POST /rpc", "description": "Incrementally build the local module-to-1C-object name map. By default enriches already indexed code modules only; deep=true also decodes objects without code-index rows. Processes one bounded page, returns a resumable next_cursor, and never writes platform SQL."}, {"name": "metadata.module_owner_cache.prune", "transport": "POST /rpc", "description": "Targeted local module-owner cache cleanup. Prefer a 1C owner ref or kind/name; owner GUID is resolved internally. Generated module_ref selectors remain accepted. Supports dry_run JSON boolean and never writes platform SQL."}, {"name": "extensions.list", "transport": "GET /extensions or POST /rpc", "description": "Публичный список расширений конфигурации: имя, порядок, дата обновления, активность и GUID. require base_id, limit >= 1 and offset >= 0 when provided. Технические поля скрыты, если явно не передан include_storage=true."}, {"name": "extension.cache.status", "transport": "POST /rpc", "description": "Report extension route cache freshness grouped by extension/kind, including stale counts and oldest validation timestamps."}, @@ -824,21 +871,27 @@ METHODS = [ {"name": "extension.objects.find", "transport": "POST /rpc", "description": "Fast search for extension metadata objects by extension name/GUID, object kind, GUID, or name fragment. Defaults to state=working: saved ConfigCASSave forms are returned over active/cache/manifest objects and marked saved_only or saved_override. Use state=active for activated metadata only, state=save for saved-state only, state=both to compare. Cached route candidates are live-validated before use; pass refresh_cache=true to skip cached candidates and rebuild from live sources. Pass full_scan=true only when a slower ConfigCAS payload scan is required. Returns routes and safe read selectors."}, {"name": "metadata.code_index.build", "transport": "POST /rpc", "description": "Build or warm a SQL-derived BSL module cache from live storage. SQL remains authoritative; cached rows store payload/text hashes and optional local vector chunks."}, {"name": "metadata.code_index.status", "transport": "POST /rpc", "description": "Report BSL code index and vector chunk cache counts. Status is informational; individual answers still require SQL verification."}, - {"name": "metadata.code_index.search", "transport": "POST /rpc", "description": "Fast BSL lexical search over metadata_code_index_cache. Default mode verifies candidates against live SQL hashes before returning freshness."}, + {"name": "metadata.code_index.search", "transport": "POST /rpc", "description": "Hybrid-safe BSL lexical search over the local code index. strict=true is the default: cached candidates are verified against live SQL, stale rows are refreshed and discarded, saved-state overlays shadow active rows, and a dirty index falls back to live SQL."}, {"name": "metadata.code_index.verify", "transport": "POST /rpc", "description": "Verify one cached module_ref against current live SQL payload/text hashes and report cache_hit_verified or cache_hit_stale."}, {"name": "metadata.code_index.refresh_changed", "transport": "POST /rpc", "description": "Verify cached search candidates and rebuild changed modules from live SQL. Intended for small operational refreshes, not full rebuilds."}, - {"name": "metadata.code_vector.search", "transport": "POST /rpc", "description": "Vector-like search over cached BSL chunks using local hashing embeddings or supplied query_embedding. Candidates are revalidated by default; vector cache is never authoritative."}, + {"name": "metadata.code_index.sync_pending", "transport": "POST /rpc", "description": "Process the local code-index dirty/outbox queue from live SQL. Exact saved-state files are rebuilt incrementally; missing saved overlays are removed so active code becomes visible again."}, + {"name": "metadata.code_index.poll_changes", "transport": "POST /rpc", "description": "Poll live 1C storage for code-index changes made outside the adapter. Saved-state tables are checked by default; include_active=true also scans active tables. New/deleted files and changed hashes are synchronized incrementally."}, + {"name": "metadata.code_vector.pending", "transport": "POST /rpc", "description": "List current BSL chunks that do not yet have an embedding for the requested model. Returns chunk_id/text_sha1 preconditions for an external local or OpenAI-compatible embedding worker."}, + {"name": "metadata.code_vector.embedding.upsert", "transport": "POST /rpc", "description": "Store one externally computed BSL chunk embedding only when chunk_id and text_sha1 still match the current local code index. Credentials and model endpoints are never stored."}, + {"name": "metadata.code_vector.search", "transport": "POST /rpc", "description": "Hybrid lexical/vector search over cached BSL chunks using local hashing embeddings or supplied query_embedding. strict=true is the default: overfetched candidates are live-verified, stale rows are refreshed and discarded, saved-state overlays shadow active rows, and dirty coverage includes a live fallback. Vector cache is never authoritative."}, {"name": "schema.tables.list", "transport": "POST /rpc", "description": "Low-level diagnostic table list for developers. Requires diagnostic=true. Parameters: limit JSON integer, timeout_seconds JSON integer, like JSON string, include_columns JSON boolean."}, {"name": "storage.files.list", "transport": "POST /rpc", "description": "Low-level diagnostic list of storage payload records. Requires diagnostic=true."}, {"name": "storage.file.get", "transport": "POST /rpc", "description": "Low-level diagnostic read of one storage payload record. Requires diagnostic=true. Parameters: table, file_name, include_payload JSON boolean, timeout_seconds JSON integer."}, {"name": "storage.saved_state.apply_proposal", "transport": "POST /rpc", "description": "Apply a reviewed encoded proposal to ConfigSave/ConfigCASSave with backup, sha1 precondition, transaction, and readback verification. Requires allow_sql_saved_state_apply=true."}, {"name": "storage.saved_state.rollback", "transport": "POST /rpc", "description": "Rollback a saved-state apply by backup_id or backup_path. Requires allow_sql_saved_state_rollback=true."}, {"name": "storage.saved_state.backups.list", "transport": "POST /rpc", "description": "List local saved-state apply backups with optional base_id/table/file_name filters."}, + {"name": "storage.saved_state.backups.prune", "transport": "POST /rpc", "description": "Prune old local saved-state backup artifacts by base/table/file filters. Defaults to dry_run=true, preserves keep_latest backups, and requires confirm_delete=true for deletion. Never writes the 1C database."}, {"name": "metadata.dbnames.summary", "transport": "POST /rpc", "description": "Low-level diagnostic DBNames summary for developers. Requires diagnostic=true."}, {"name": "code.search", "transport": "POST /rpc", "description": "Search decoded BSL in configuration_view=effective_working by default: the logical Designer snapshot, with saved development changes and extension layers preferred. Use runtime_applied only for code executable now, or compare for both. Object scope accepts ref, kind/name/guid, or object_type/object_name/object_guid. Every item has read_selector.method is code.read; reuse that selector directly. Results expose logical owners and read selectors, not Config/ConfigSave internals."}, {"name": "code.read", "transport": "POST /rpc", "description": "Read module or routine text in configuration_view=effective_working by default, by logical owner selector or a prior code.search read_selector. Pass module_ref from code.search read_selector unchanged when it is present. runtime_applied reads code executable now; compare returns both. The response identifies the view so saved Designer changes are never mistaken for already activated runtime code."}, {"name": "code.symbol.resolve", "transport": "POST /rpc", "description": "Conservatively resolve a BSL expression inside a concrete module/routine context. Full 1C paths and context-proven members are metadata; routine parameters, local variables, and short object names remain code symbols."}, {"name": "templates.bindings", "transport": "POST /rpc", "description": "Extract template dependencies, parameters/fields bindings and owner chain for a report/processing/form object."}, + {"name": "template.field.lineage", "transport": "POST /rpc", "description": "Trace one static print-template parameter through a BSL area assignment to its local formula or source row fields. SQL-decoded evidence only; helper calls and dynamic area names remain partial."}, {"name": "diagnostics.call_chain", "transport": "POST /rpc", "description": "Build diagnostic call chain for a code entrypoint: entry method owner/module, possible overrides, and detected static usage links in the same object scope."}, {"name": "payload.diff", "transport": "POST /rpc", "description": "Low-level diagnostic comparison of two 1C payloads from live storage or inline bytes/text. Returns byte sha1/size changes, text unified diff, tree scalar changes, string changes, and compact undecoded evidence. Requires diagnostic=true."}, {"name": "codec.decode", "transport": "POST /rpc", "description": "Low-level diagnostic decode of a 1C payload record into text/tree. Requires diagnostic=true."}, @@ -852,6 +905,8 @@ METHODS = [ {"name": "data.get", "transport": "POST /rpc", "description": "Read one application data record by public 1C object selector and 32-character reference id."}, {"name": "data.count", "transport": "POST /rpc", "description": "Count application data records by public 1C object selector and exact logical filters."}, {"name": "data.query", "transport": "POST /rpc", "description": "Universal logical application-data query facade over data.list/data.count."}, + {"name": "additional_attributes.find", "transport": "POST /rpc", "description": "Read-only discovery of additional requisites/properties. Searches a ChartOfCharacteristicTypes through public selectors and returns an explicit not_found/empty_source diagnostic instead of a silent empty list."}, + {"name": "additional_attributes.storage.resolve", "transport": "POST /rpc", "description": "Read-only resolution of the confirmed storage route for an additional requisite. It returns a join only when the property and Object/Property/Value fields are evidenced by live metadata; it never guesses _Fld columns."}, {"name": "data.present", "transport": "POST /rpc", "description": "Resolve a 1C application-data reference to a public presentation without exposing SQL identifiers."}, {"name": "data.movements", "transport": "POST /rpc", "description": "Read register movements for a recorder reference using a public register selector."}, {"name": "data.virtual", "transport": "POST /rpc", "description": "Read 1C-style register virtual views: slices for information registers and balances/turnovers for accumulation registers."}, @@ -918,6 +973,38 @@ METHOD_INPUT_SCHEMAS.update( for method in DATA_OBJECT_SELECTOR_METHODS } ) + +# Application-data writes deliberately use a separate schema from the read +# facade. A caller never supplies a physical table or SQL expression: the +# public object name is resolved through DBNames and the field names are +# checked against the live logical schema. +METHOD_INPUT_SCHEMAS["metadata.relationship.verify"] = { + "type": "object", + "required": ["base_id", "member"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string"}, + "ref": {"type": "string", "description": "Source public 1C object ref, for example Document.СписаниеЗапасов."}, + "kind": {"type": "string"}, "name": {"type": "string"}, "guid": {"type": "string"}, + "member": {"type": "string", "description": "Exact source attribute or tabular-section column name."}, + "target_ref": {"type": "string", "description": "Optional proposed target ref. A mismatch returns not_confirmed."}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 300}, + }, +} +METHOD_INPUT_SCHEMAS["metadata.relationship.find"] = { + "type": "object", + "required": ["base_id", "target_ref"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string"}, + "ref": {"type": "string", "description": "Source public 1C object ref."}, + "kind": {"type": "string"}, "name": {"type": "string"}, "guid": {"type": "string"}, + "target_ref": {"type": "string", "description": "Public 1C object ref to look for in declared reference types."}, + "direction": {"type": "string", "enum": ["outgoing", "incoming", "either"]}, + "execution_mode": {"type": "string", "enum": ["job"]}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 300}, + }, +} METHOD_INPUT_SCHEMAS["metadata.support.decode"] = { "type": "object", "required": ["base_id"], @@ -1204,6 +1291,18 @@ METHOD_INPUT_SCHEMAS["metadata.write.plan"] = { ], } METHOD_INPUT_SCHEMAS["metadata.write.preflight"] = copy.deepcopy(METHOD_INPUT_SCHEMAS["metadata.write.plan"]) +METHOD_INPUT_SCHEMAS["metadata.write_target.resolve"] = { + "type": "object", + "required": ["base_id", "path"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "path": {"type": "string", "description": "Full public path, for example Расширение..Отчеты..Формы..МодульФормы."}, + "write_target": {"type": "object", "additionalProperties": True, "description": "Optional existing explicit target; its path and expected extension must agree with path."}, + "old": {"type": "string", "description": "Optional exact old code fragment. When supplied, resolve verifies it in the confirmed active form module before any prepare/write step."}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 300}, + }, +} METHOD_INPUT_SCHEMAS["metadata.code_index.build"] = { "type": "object", "required": ["base_id"], @@ -1417,6 +1516,140 @@ METHOD_INPUT_SCHEMAS["metadata.saved_state.status"] = { }, }, } +METHOD_INPUT_SCHEMAS["configuration.activation.status"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": False, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "layer": { + "type": "string", + "enum": ["all", "base_saved_state", "extension_saved_state"], + "description": "Saved-state layer scope. Defaults to all.", + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 5000, + "description": "Maximum saved-state rows checked per layer. Defaults to 5000.", + }, + "timeout_seconds": {"type": "integer", "minimum": 1}, + "include_files": { + "type": "boolean", + "description": "Include changed file diagnostics only when include_storage=true.", + }, + "include_storage": { + "type": "boolean", + "description": "Expose storage tables, file names, hashes, and diff selectors.", + }, + }, +} +METHOD_INPUT_SCHEMAS["configuration.activation.plan"] = copy.deepcopy( + METHOD_INPUT_SCHEMAS["configuration.activation.status"] +) +METHOD_INPUT_SCHEMAS["configuration.activation.request"] = copy.deepcopy( + METHOD_INPUT_SCHEMAS["configuration.activation.status"] +) +METHOD_INPUT_SCHEMAS["configuration.activation.request"]["properties"].pop("include_files", None) +METHOD_INPUT_SCHEMAS["configuration.activation.request"]["properties"].pop("include_storage", None) +METHOD_INPUT_SCHEMAS["configuration.activation.request"]["properties"]["ttl_seconds"] = { + "type": "integer", + "minimum": 60, + "maximum": 86400, + "description": "Adapter-local request lifetime. Defaults to 1800 seconds.", +} +METHOD_INPUT_SCHEMAS["configuration.activation.request.status"] = { + "type": "object", + "required": ["request_id"], + "additionalProperties": False, + "properties": { + "base_id": {"type": "string", "description": "Optional expected base identifier."}, + "request_id": {"type": "string", "description": "Activation request id."}, + }, +} +METHOD_INPUT_SCHEMAS["configuration.activation.request.cancel"] = { + "type": "object", + "required": ["request_id", "confirm_cancel"], + "additionalProperties": False, + "properties": { + "base_id": {"type": "string", "description": "Optional expected base identifier."}, + "request_id": {"type": "string", "description": "Activation request id."}, + "confirm_cancel": { + "type": "boolean", + "const": True, + "description": "Explicit confirmation that this exact request should be cancelled.", + }, + }, +} +METHOD_INPUT_SCHEMAS["configuration.activation.audit"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": False, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "limit": {"type": "integer", "minimum": 1, "maximum": 1000}, + "status": {"type": "string", "description": "Optional exact request status filter."}, + }, +} +METHOD_INPUT_SCHEMAS["configuration.activation.capabilities"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": False, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "layer": { + "type": "string", + "enum": ["all", "base_saved_state", "extension_saved_state"], + }, + }, +} +METHOD_INPUT_SCHEMAS["configuration.activation.bridge.probe"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": False, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "layer": { + "type": "string", + "enum": ["all", "base_saved_state", "extension_saved_state"], + }, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60}, + }, +} +METHOD_INPUT_SCHEMAS["configuration.activation.execute"] = { + "type": "object", + "required": ["base_id", "request_id", "confirm_activation"], + "additionalProperties": False, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "request_id": {"type": "string", "description": "Activation request id."}, + "mode": { + "type": "string", + "enum": ["debug"], + "description": "Only debug is currently supported.", + }, + "confirm_activation": { + "type": "boolean", + "const": True, + "description": "Explicit confirmation of the exact fingerprinted request.", + }, + "bridge_debug": { + "type": "boolean", + "description": "Also require an end-to-end local/HTTP runner debug receipt. Designer is not started.", + }, + "timeout_seconds": {"type": "integer", "minimum": 1}, + }, +} +METHOD_INPUT_SCHEMAS["configuration.activation.verify"] = { + "type": "object", + "required": ["base_id", "request_id"], + "additionalProperties": False, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "request_id": {"type": "string", "description": "Activation request id."}, + "timeout_seconds": {"type": "integer", "minimum": 1}, + }, +} METHOD_INPUT_SCHEMAS["metadata.saved_state.prepare"] = { "type": "object", "required": ["base_id"], @@ -1434,6 +1667,11 @@ METHOD_INPUT_SCHEMAS["metadata.saved_state.prepare"] = { "object_type": {"type": "string", "description": "MCP alias of kind."}, "object_name": {"type": "string", "description": "MCP alias of name."}, "extension": {"type": "string", "description": "Optional extension name or GUID."}, + "form": {"type": "string", "description": "Optional extension form name. Requires public owner ref or kind+name; the adapter resolves its payload without a global scan."}, + "module_ref": {"type": "string", "description": "Opaque module reference returned by the adapter; do not substitute a public module name."}, + "file_name": {"type": "string", "description": "Optional low-level follow-up selector returned by the adapter."}, + "file_names": {"type": "array", "items": {"type": "string"}, "description": "Optional explicit atomic storage group returned by the adapter."}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 240, "description": "Per-call SQL timeout; the response reports completed stages."}, "target_table": { "type": "string", "enum": ["ConfigSave", "ConfigCASSave"], @@ -1441,12 +1679,31 @@ METHOD_INPUT_SCHEMAS["metadata.saved_state.prepare"] = { }, "mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify"]}, "allow_sql_saved_state_prepare": {"type": "boolean"}, + "merge_missing": {"type": "boolean", "description": "Copy only active files absent from the saved-state target; never overwrite existing saved files."}, "include_storage": { "type": "boolean", "description": "Expose SQL tables, database, files, hashes, row details, and low-level apply diagnostics.", }, }, } +METHOD_INPUT_SCHEMAS["metadata.saved_state.ensure"] = { + **METHOD_INPUT_SCHEMAS["metadata.saved_state.prepare"], + "properties": { + **METHOD_INPUT_SCHEMAS["metadata.saved_state.prepare"]["properties"], + "mode": {"type": "string", "enum": ["plan", "apply", "apply_and_verify"]}, + }, +} +METHOD_INPUT_SCHEMAS["metadata.saved_state.ensure.rollback"] = { + "type": "object", + "required": ["base_id", "prepare_receipt_id", "allow_sql_saved_state_rollback"], + "additionalProperties": False, + "properties": { + "base_id": {"type": "string", "description": "Adapter base identifier."}, + "prepare_receipt_id": {"type": "string", "description": "Opaque receipt id returned by metadata.saved_state.ensure apply."}, + "allow_sql_saved_state_rollback": {"type": "boolean", "const": True}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 240}, + }, +} METHOD_INPUT_SCHEMAS["metadata.saved_state.changes.list"] = { "type": "object", "required": ["base_id"], @@ -1625,6 +1882,30 @@ for _access_object_method in ("access.object_keys.resolve", "access.object.expla "properties": dict(_ACCESS_OBJECT_FILTER_PROPERTIES), } +METHOD_INPUT_SCHEMAS["storage.saved_state.backups.prune"] = { + "type": "object", + "required": ["base_id"], + "additionalProperties": False, + "properties": { + "base_id": {"type": "string"}, + "table": {"type": "string", "enum": ["ConfigSave", "ConfigCASSave"]}, + "file_name": {"type": "string"}, + "older_than_days": {"type": "integer", "minimum": 1, "maximum": 3650, "default": 30}, + "keep_latest": {"type": "integer", "minimum": 0, "maximum": 10000, "default": 20}, + "limit": {"type": "integer", "minimum": 1, "maximum": 10000, "default": 500}, + "dry_run": {"type": "boolean", "default": True}, + "confirm_delete": {"type": "boolean", "default": False}, + "diagnostic": { + "type": "boolean", + "description": "Required when the method is forwarded through the generic MCP diagnostic policy.", + }, + }, + "notes": [ + "Defaults to dry_run=true and never writes the 1C database.", + "Actual local backup deletion requires dry_run=false and confirm_delete=true.", + ], +} + RELATED_SECTION_RULES = { "Document": [ {"path": "4", "category": "Template"}, @@ -1701,7 +1982,7 @@ KIND_CAPABILITIES = { "BusinessProcess": ["list", "get", "attributes", "forms", "modules"], "Task": ["list", "get", "attributes", "addressing_attributes", "forms", "commands", "modules"], "Constant": ["list", "get", "value_type", "properties", "special_details"], - "ChartOfCharacteristicTypes": ["list", "get", "attributes", "tabular_sections", "forms", "templates", "modules"], + "ChartOfCharacteristicTypes": ["list", "get", "attributes", "tabular_sections", "forms", "templates", "modules", "properties", "special_details"], "ChartOfAccounts": ["list", "get", "attributes", "tabular_sections", "accounting_flags", "forms", "modules"], "ChartOfCalculationTypes": ["list", "get", "attributes", "tabular_sections", "forms", "modules", "properties", "special_details"], "ExchangePlan": ["list", "get", "attributes", "tabular_sections", "forms", "templates", "commands", "modules"], @@ -2003,7 +2284,7 @@ def validate_sql_only_runtime_payload(method: str, payload: dict[str, Any]) -> d def child_not_found(method: str, child_kind_ru: str, child_name: Any, object_card: dict[str, Any], *, base_id: str) -> dict[str, Any]: not_found_phrase = "не найдена" if child_kind_ru.lower().endswith("а") else "не найден" - return { + result = { "schema": "onec_adapter_request_error.v1", "method": method, "status": "not_found", @@ -2014,6 +2295,7 @@ def child_not_found(method: str, child_kind_ru: str, child_name: Any, object_car "message": f"{child_kind_ru} `{child_name}` {not_found_phrase} у объекта `{object_card.get('name') or object_card.get('guid')}`.", }, } + return result def optional_string_filter(payload: dict[str, Any], keys: list[str], *, method: str) -> tuple[Any, dict[str, Any] | None]: @@ -2310,9 +2592,11 @@ def normalize_object_ref_payload(payload: dict[str, Any], method: str) -> dict[s def normalize_object_selector_aliases(payload: dict[str, Any], method: str) -> dict[str, Any]: """Fill kind/name/guid from MCP-friendly object_type/object_name/object_guid aliases.""" - selector_error = validate_object_selector_arguments(payload, method, include_view=False) - if selector_error: - return selector_error + module_ref = str(payload.get("module_ref") or "").strip() + if not module_ref: + selector_error = validate_object_selector_arguments(payload, method, include_view=False) + if selector_error: + return selector_error normalized = normalize_object_ref_payload(payload, method) if isinstance(normalized, dict) and normalized.get("status") == "invalid_argument": return normalized @@ -2442,6 +2726,7 @@ OBJECT_SELECTOR_METHOD_CAPABILITIES = { "metadata.object.member.add", "metadata.object.decode", "metadata.object.parts", + "metadata.object.components", "metadata.object.modules", "metadata.object.related", "metadata.object.forms", @@ -2501,6 +2786,16 @@ OBJECT_SELECTOR_METHOD_CAPABILITIES.update( **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "requires_object_selector": True, }, + "metadata.relationship.verify": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "requires_object_selector": True, + "read_only": True, + }, + "metadata.relationship.find": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "requires_object_selector": True, + "read_only": True, + }, "metadata.object.full": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "requires_object_selector": True, @@ -2527,6 +2822,13 @@ OBJECT_SELECTOR_METHOD_CAPABILITIES.update( "selector_purpose": "cache_owner_scope", "local_cache_mutation": True, }, + "metadata.module_owner_cache.backfill": { + **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, + "accepts_ordinal": False, + "requires_object_selector": False, + "selector_purpose": "cache_owner_backfill_scope", + "local_cache_mutation": True, + }, "modules.search": { **OBJECT_SELECTOR_DEFAULT_CAPABILITIES, "accepts_extension": True, @@ -2831,6 +3133,7 @@ HTTP_POST_METHOD_ROUTES = { "/metadata/snapshot": "metadata.snapshot", "/metadata/object/decode": "metadata.object.decode", "/metadata/object/parts": "metadata.object.parts", + "/metadata/object/components": "metadata.object.components", "/metadata/object/modules": "metadata.object.modules", "/metadata/object/related": "metadata.object.related", "/metadata/object/forms": "metadata.object.forms", @@ -2846,6 +3149,16 @@ HTTP_POST_METHOD_ROUTES = { "/metadata/form/decode": "metadata.form.decode", "/metadata/form/write-target/resolve": "metadata.form.write_target.resolve", "/metadata/saved-state/forms/search": "metadata.saved_state.forms.search", + "/configuration/activation/status": "configuration.activation.status", + "/configuration/activation/plan": "configuration.activation.plan", + "/configuration/activation/request": "configuration.activation.request", + "/configuration/activation/request/status": "configuration.activation.request.status", + "/configuration/activation/request/cancel": "configuration.activation.request.cancel", + "/configuration/activation/audit": "configuration.activation.audit", + "/configuration/activation/capabilities": "configuration.activation.capabilities", + "/configuration/activation/bridge/probe": "configuration.activation.bridge.probe", + "/configuration/activation/execute": "configuration.activation.execute", + "/configuration/activation/verify": "configuration.activation.verify", "/metadata/form/element-write": "metadata.form.element.write", "/metadata/form/element-write-apply": "metadata.form.element.write_apply", "/metadata/write-plan": "metadata.write.plan", @@ -2889,6 +3202,7 @@ HTTP_POST_METHOD_ROUTES = { "/storage/saved-state/apply-proposal": "storage.saved_state.apply_proposal", "/storage/saved-state/rollback": "storage.saved_state.rollback", "/storage/saved-state/backups": "storage.saved_state.backups.list", + "/storage/saved-state/backups/prune": "storage.saved_state.backups.prune", } @@ -2906,9 +3220,39 @@ def adapter_http_routes_for_method(method: str) -> list[dict[str, str]]: return routes +METHOD_INPUT_SCHEMAS["form.field.lineage"] = { + "type": "object", + "required": ["base_id", "field"], + "additionalProperties": True, + "properties": { + "base_id": {"type": "string"}, + "ref": {"type": "string", "description": "Public object ref, e.g. Document.УОП_ПланированиеМеню."}, + "kind": {"type": "string"}, + "name": {"type": "string"}, + "guid": {"type": "string"}, + "field": {"type": "string", "description": "Form element, attribute, command, or displayed field name."}, + "form": {"type": "string"}, + "form_title": {"type": "string"}, + "analysis_profile": {"type": "string", "enum": ["full", "fast"], "default": "full"}, + "match_mode": {"type": "string", "enum": ["contains", "exact", "semantic"], "default": "contains"}, + "include_related": {"type": "boolean", "default": False}, + "include_binding_inference": {"type": "boolean", "default": True}, + "include_call_chain": {"type": "boolean", "default": True}, + "include_calculation": {"type": "boolean", "default": True}, + "call_chain_depth": {"type": "integer", "minimum": 0, "maximum": 3, "default": 2}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 30}, + "execution_mode": {"type": "string", "enum": ["job"]}, + }, + "examples": [{ + "base_id": "upo_test", "ref": "Document.УОП_ПланированиеМеню", + "form": "ФормаДокумента", "field": "ВидыКоличествЦенаПродажи", + "match_mode": "exact", "include_calculation": False, + }], +} + + def adapter_method_registry() -> dict[str, dict[str, Any]]: """Single runtime registry used by help, jobs, RPC validation, and MCP checks.""" - return { str(row.get("name") or ""): public_method_row(row) for row in METHODS @@ -2951,6 +3295,103 @@ def is_extension_object(item: dict[str, Any], top: dict[str, Any]) -> bool: return bool(item.get("extension_routes")) or is_extension_path(top.get("relative_path") or top.get("path")) +ADAPTER_HEALTH_CAPABILITIES = [ + "live_sql", + "read-only-query", + "extensions", + "saved-state-read", + "saved-state-write", + "active-saved-compare", + "configuration-activation-status", + "repository-coordination", + "support-decode", + "code-index", +] + + +def adapter_health_features(*, configured: bool, base_id: str | None) -> dict[str, Any]: + base_required = not bool(base_id) + available = bool(configured and base_id) + return { + "saved_state_read": { + "available": available, + "requires_base_id": base_required, + "tables": ["ConfigSave", "ConfigCASSave"], + }, + "saved_state_write": { + "available": available, + "requires_base_id": base_required, + "agent_method": "code.write", + "target": "saved_state", + "activation_state": "not_activated", + "active_configuration_write": False, + }, + "active_saved_compare": { + "available": available, + "requires_base_id": base_required, + "methods": ["code.read", "metadata.saved_state.diff"], + "module_text_sha1": True, + }, + "repository_coordination": { + "available": bool(base_id), + "requires_base_id": base_required, + "native_lock_verification": False, + "manual_confirmation_supported": True, + }, + "support_decode": { + "available": available, + "requires_base_id": base_required, + "source": "live_sql", + }, + "configuration_activation": { + "available": available, + "status": "debug_acceptance_available", + "methods": [ + "configuration.activation.status", + "configuration.activation.plan", + "configuration.activation.request", + "configuration.activation.request.status", + "configuration.activation.request.cancel", + "configuration.activation.audit", + "configuration.activation.capabilities", + "configuration.activation.bridge.probe", + "configuration.activation.execute", + "configuration.activation.verify", + ], + "debug_acceptance_available": True, + "execution_available": False, + "reason": "designer_activation_bridge_not_implemented", + "saved_state_only": True, + "read_only": True, + "state_store": "local_sqlite", + }, + } + + +ADAPTER_RUNTIME_LOCK = threading.Lock() +ADAPTER_RUNTIME: dict[str, Any] = {"draining": False, "active_rpc": {}} + + +def adapter_runtime_begin_request(request_id: str, method: str) -> None: + with ADAPTER_RUNTIME_LOCK: + ADAPTER_RUNTIME["active_rpc"][request_id] = method + + +def adapter_runtime_end_request(request_id: str) -> None: + with ADAPTER_RUNTIME_LOCK: + ADAPTER_RUNTIME["active_rpc"].pop(request_id, None) + + +def adapter_runtime_health() -> dict[str, Any]: + with ADAPTER_RUNTIME_LOCK: + methods = list(ADAPTER_RUNTIME["active_rpc"].values()) + return { + "state": "draining" if ADAPTER_RUNTIME["draining"] else "ready", + "active_rpc_count": len(methods), + "active_methods": dict(sorted(Counter(methods).items())), + } + + class AdapterState: def health(self, *, base_id: str | None = None) -> dict[str, Any]: if not base_id: @@ -2959,7 +3400,9 @@ class AdapterState: "status": "ok", "base_id": None, "contract_version": ADAPTER_CONTRACT_VERSION, - "capabilities": ["live_sql", "read-only-query", "extensions"], + "runtime": adapter_runtime_health(), + "capabilities": ADAPTER_HEALTH_CAPABILITIES, + "features": adapter_health_features(configured=False, base_id=None), "diagnostics": {"message": "Pass base_id to check a concrete 1C database source."}, } resolved_base_id = str(base_id) @@ -2969,13 +3412,15 @@ class AdapterState: "status": "ok" if config else "degraded", "base_id": resolved_base_id, "contract_version": ADAPTER_CONTRACT_VERSION, + "runtime": adapter_runtime_health(), "live_sql": { "configured": bool(config), "server": config.get("server") if config else None, "database": config.get("database") if config else None, "user": config.get("user") if config else None, }, - "capabilities": ["live_sql", "read-only-query", "extensions"], + "capabilities": ADAPTER_HEALTH_CAPABILITIES, + "features": adapter_health_features(configured=bool(config), base_id=resolved_base_id), } if config_error: result["diagnostics"] = config_error @@ -3082,6 +3527,22 @@ def public_metadata_row(row: dict[str, Any], *, include_storage: bool = False) - """Return a 1C-facing metadata row; physical storage is opt-in.""" public = dict(row) + if public.get("name") not in {None, ""}: + public["name"] = best_text_variant(public.get("name")) + if public.get("synonym") not in {None, ""}: + public["synonym"] = best_text_variant(public.get("synonym")) + identity = public.get("identity") + if isinstance(identity, dict): + identity = dict(identity) + if identity.get("name") not in {None, ""}: + identity["name"] = best_text_variant(identity.get("name")) + synonyms = identity.get("synonyms") + if isinstance(synonyms, dict): + identity["synonyms"] = { + key: best_text_variant(value) + for key, value in synonyms.items() + } + public["identity"] = identity public_ref = object_selector_ref(public.get("kind"), public.get("name")) if public_ref: public["ref"] = public_ref @@ -3281,6 +3742,16 @@ METADATA_WRITE_CAPABILITIES = { "apply_method": "storage.saved_state.apply_proposal", "guards": ["expected_sha1", "expected_text_sha1", "backup", "readback_verification"], }, + "report_scd": { + "status": "supported_saved_state", + "targets": ["report_data_composition_schema", "dataset_query", "calculated_expression", "resource_expression"], + "operations": ["inspect", "prepare", "plan_scalar_patch", "apply_and_verify", "semantic_compare", "rollback_prepare"], + "agent_method": "scd.patch", + "write_layer": "save", + "apply_method": "storage.saved_state.apply_proposal", + "guards": ["expected_sha1", "prepare_receipt", "proposal_review", "semantic_readback", "semantic_compare"], + "notes": ["The report extension route is ConfigCAS -> ConfigCASSave; a base report route is Config -> ConfigSave."], + }, "form_element_properties": { "status": "partial_saved_state", "targets": ["form_element", "form_command", "form_attribute"], @@ -3339,6 +3810,57 @@ METADATA_WRITE_CAPABILITIES = { } +# This is deliberately data exposed by metadata.write.capabilities rather than +# a free-form convention for agents. A report edit frequently spans all of +# these carriers; its BSL handler must exist before the SCD/form is connected +# to it. Entries with a read-only status remain visible so an agent stops +# before proposing an unsafe binary/container rewrite. +METADATA_WRITE_WORKFLOW = [ + { + "priority": 1, + "area": "bsl_modules", + "purpose": "Create or change executable BSL routines, including report and form handlers.", + "status": "supported_saved_state", + "methods": ["modules.read", "code.read", "metadata.write.plan", "code.write"], + }, + { + "priority": 2, + "area": "report_scd", + "purpose": "Change a report Data Composition Schema query or scalar expression after the required BSL parameters are known.", + "status": "supported_saved_state", + "methods": ["scd.inspect", "scd.prepare", "scd.patch", "scd.compare"], + }, + { + "priority": 3, + "area": "templates", + "purpose": "Inspect report layouts/templates and stop if a change would require binary or spreadsheet round-trip encoding.", + "status": "read_only", + "methods": ["templates.read"], + }, + { + "priority": 4, + "area": "form_element_properties", + "purpose": "Bind commands and make supported form property changes after their handlers exist.", + "status": "partial_saved_state", + "methods": ["metadata.write.plan", "metadata.write"], + }, + { + "priority": 5, + "area": "object_metadata", + "purpose": "Apply supported object-level metadata changes only after dependent code/forms have a verified plan.", + "status": "partial_saved_state", + "methods": ["metadata.write.plan", "metadata.write"], + }, + { + "priority": 6, + "area": "scheduled_jobs", + "purpose": "Change a schedule only after its common-module handler has been reviewed or changed in priority 1.", + "status": "partial_saved_state", + "methods": ["metadata.write.plan", "metadata.write"], + }, +] + + def module_suffix_from_file_name(file_name: Any) -> str | None: match = re.search(r"\.(\d+)$", str(file_name or "")) return match.group(1) if match else None @@ -3378,10 +3900,19 @@ def public_module_role( return {"kind": service_role[0], "name": service_role[1], **({"suffix": suffix} if suffix else {})} if kind == "SettingsStorage": return {"kind": "manager_module", "name": "Модуль менеджера", **({"suffix": suffix} if suffix else {})} - if suffix == "2": - return {"kind": "command_module", "name": "Модуль команды", **({"suffix": suffix} if suffix else {})} - if suffix == "3": - return {"kind": "manager_module", "name": "Модуль менеджера", **({"suffix": suffix} if suffix else {})} + # A stream suffix is storage evidence, not proof of a Configurator-tree + # role. In particular, associating every ``.2`` stream with a Command + # invented a command path for extension reports without decoding a Command + # child object. Promote these roles only through metadata.object.commands + # (or another owner-proven decoder). + if suffix in {"2", "3"}: + return { + "kind": "bsl_module", + "name": "Модуль BSL с неподтвержденной ролью", + "role_status": "unconfirmed", + "configurator_path_status": "unconfirmed", + "suffix": suffix, + } if suffix == "0": if kind in REGISTER_MODULE_OWNER_KINDS: return {"kind": "record_set_module", "name": "Модуль набора записей", "suffix": suffix} @@ -3422,18 +3953,26 @@ def public_module_row( ) -> dict[str, Any]: public = dict(module) kind = canonical_kind(str(owner_kind or "")) - role = public_module_role( - owner_kind=kind, - suffix=str(public.get("suffix") or module_suffix_from_module_id(public.get("module_id")) or ""), - ordinal=ordinal, - # A common module is addressed in 1C code by the metadata object name. - # The raw row name is a Config/ConfigCAS stream coordinate and must - # never become its public name. - current_name=str(owner_name or "") if kind == "CommonModule" else None, + is_form_module = str(public.get("kind") or "") == "form_module" or isinstance(public.get("form"), dict) + role = ( + {"kind": "form_module", "name": "Модуль формы"} + if is_form_module + else public_module_role( + owner_kind=kind, + suffix=str(public.get("suffix") or module_suffix_from_module_id(public.get("module_id")) or ""), + ordinal=ordinal, + # A common module is addressed in 1C code by the metadata object name. + # The raw row name is a Config/ConfigCAS stream coordinate and must + # never become its public name. + current_name=str(owner_name or "") if kind == "CommonModule" else None, + ) ) original_name = public.get("name") public["name"] = role.get("name") public["kind"] = role.get("kind") + for evidence_key in ("role_status", "configurator_path_status"): + if role.get(evidence_key): + public[evidence_key] = role[evidence_key] if ordinal is not None: public["module_ordinal"] = ordinal if include_storage and original_name and original_name != public.get("name"): @@ -3490,7 +4029,25 @@ def public_module_with_qualified_name( 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) + form_payload = module.get("form") if isinstance(module.get("form"), dict) else {} + if form_payload: + qualified_name = ".".join( + part + for part in [ + (owner or {}).get("name"), + "Форма", + form_payload.get("name"), + public.get("name"), + ] + if part + ) + public["form"] = { + key: form_payload.get(key) + for key in ("guid", "name", "synonym") + if form_payload.get(key) not in {None, ""} + } + else: + qualified_name = public_code_qualified_name(owner=owner, module=public) if qualified_name: public["qualified_name"] = qualified_name public["display_name"] = qualified_name @@ -3817,6 +4374,38 @@ def form_public_sections(profile: dict[str, Any]) -> dict[str, Any]: } +def form_static_structure(profile: dict[str, Any]) -> dict[str, Any]: + """Compact static form projection without manufacturing a logical parent tree. + + Payload paths describe serialized nodes, not necessarily the parent-child + relationship shown by the 1C form editor. Until that mapping is proven, + expose each decoded element and mark the unavailable edge explicitly. + """ + elements: list[dict[str, Any]] = [] + for item in profile.get("items") or []: + if not isinstance(item, dict): + continue + element = { + key: item.get(key) + for key in ("id", "name", "type_name", "path", "depth", "title", "data_path") + if item.get(key) is not None + } + if isinstance(item.get("semantic"), dict): + element["semantic"] = item["semantic"] + element["parent"] = {"status": "unresolved", "reason": "logical_form_parent_codec_unproven"} + element["children"] = {"status": "unresolved", "reason": "logical_form_parent_codec_unproven"} + elements.append(element) + return { + "schema": "onec_form_static_structure.v1", + "status": "partial" if elements else "not_found", + "elements": elements, + "capabilities": {"logical_parent_tree": False}, + "diagnostics": { + "message": "Static element facts are decoded; logical parent/child links are unresolved until their SQL codec is proven.", + }, + } + + def form_element_filter_from_payload(payload: dict[str, Any]) -> dict[str, Any]: return { "element": payload.get("element") or payload.get("element_name"), @@ -4090,6 +4679,9 @@ PUBLIC_FORBIDDEN_KEYS = { PUBLIC_ALLOWED_PATH_KEYS = { "canonical_path", "context_path", + # Logical 1C form binding, for example `Объект.ДатаПлана`; this is not a + # physical SQL route and is required to explain field lineage. + "data_path", "form_path", "input_path", "safe_as_metadata_path", @@ -5181,6 +5773,26 @@ def session_parameter_sql_details(base_id: str, tree: Any, *, table: str, timeou return {"value_type": public_pattern_value_type(base_id, config_tree_item_at_path(tree, (1, 1, 2)), table=table, timeout_seconds=timeout_seconds)} +def chart_of_characteristic_types_sql_details(base_id: str, tree: Any, *, table: str, timeout_seconds: int) -> dict[str, Any]: + """Decode the chart-level allowed value types with an explicit tree path. + + This is chart metadata, not a claim about any one characteristic record. + Record-level Type/PropertySet fields remain intentionally unresolved until + their physical mapping is evidenced by a decoded configuration definition. + """ + value_type_path = (1, 18) + value_type = public_pattern_value_type( + base_id, + config_tree_item_at_path(tree, value_type_path), + table=table, + timeout_seconds=timeout_seconds, + ) + return { + "allowed_value_types": value_type or {"status": "not_decoded_yet"}, + "evidence": {"config_path": ".".join(str(item) for item in value_type_path), "kind": "chart_metadata"}, + } + + def functional_option_sql_details(base_id: str, tree: Any, *, table: str, timeout_seconds: int) -> dict[str, Any]: location_guid = config_tree_scalar_at_path(tree, (1, 2)).strip().lower() location_map = ( @@ -5410,7 +6022,8 @@ def xdto_type_xml_details(element: ET.Element) -> dict[str, Any]: def xdto_package_xml_details(data: bytes) -> dict[str, Any]: try: - from parser.payload import decode_payload_lossless + from parser.payload import decode_payload_lossless, parse_brace_text + from parser.payload import parse_brace_text decoded = decode_payload_lossless(data or b"") payload = bytes(decoded.get("payload") or b"") @@ -6175,6 +6788,8 @@ def metadata_write_capabilities(payload: dict[str, Any]) -> dict[str, Any]: "agent_rule": "Agent-facing writes must target the saved-state layer; active configuration writes are not exposed.", "code_carriers": CODE_CARRIER_MATRIX, "write_capabilities": METADATA_WRITE_CAPABILITIES, + "registered_handlers": registered_handlers(), + "workflow_priority": METADATA_WRITE_WORKFLOW, "safe_methods": ["code.write", "metadata.write", "metadata.write.plan"], "technical_apply_method": "storage.saved_state.apply_proposal", "unsupported_summary": [ @@ -6463,6 +7078,26 @@ def get_object( if not base_id: return base_id_required("metadata.object.get") resolved_base_id = str(base_id) + request_started = time.monotonic() + request_deadline = request_started + max(1, int(timeout_seconds or 60)) + + def deadline_error(stage: str, *, scanned: int = 0) -> dict[str, Any]: + return { + "schema": "onec_metadata_object.v1", + "method": "metadata.object.get", + "status": "error", + "error": "query_timeout", + "base_id": resolved_base_id, + "diagnostics": { + "stage": stage, + "elapsed_seconds": round(time.monotonic() - request_started, 3), + "timeout_seconds": int(timeout_seconds or 60), + "scanned": scanned, + }, + } + + def remaining_seconds() -> int: + return max(0, int(request_deadline - time.monotonic())) view_value, view_error = parse_view_argument({"view": view}, "metadata.object.get") if view_error: return view_error @@ -6523,6 +7158,66 @@ def get_object( ) if not direct_file_name and storage_table == "ConfigCASSave" and public_extension_guid and is_guid_text(wanted_name): direct_file_name = f"{public_extension_guid}__{wanted_name.lower()}" + if ( + not direct_file_name + # A caller that explicitly selected Config is asking for the base + # layer. Extension auto-resolution is useful only for the effective + # ConfigCAS route; silently crossing this boundary makes origin and + # any future write target unsafe. + and storage_table == "ConfigCAS" + and wanted_name + ): + extension_match = fast_extension_object_exact_match( + resolved_base_id, + kind=wanted_kind, + name=wanted_name, + extension_guid=public_extension_guid or None, + table="ConfigCAS", + timeout_seconds=timeout_seconds, + ) + if extension_match: + origin = extension_match.get("origin") if isinstance(extension_match.get("origin"), dict) else {} + extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} + route = extension_match.get("route") if isinstance(extension_match.get("route"), dict) else {} + route_file_name = str(route.get("file_name") or "").strip() + if route_file_name: + resolved = get_object( + extension_match.get("kind") or wanted_kind, + str(extension_match.get("guid") or wanted_name), + base_id=resolved_base_id, + view=view, + limit=limit, + include_storage=include_storage, + include_semantic=include_semantic, + timeout_seconds=timeout_seconds, + table=str(route.get("table") or "ConfigCAS"), + file_name=route_file_name, + extension_guid=str(extension.get("guid") or public_extension_guid or "") or None, + resolve_semantic_types=resolve_semantic_types, + semantic_include_generic=semantic_include_generic, + semantic_categories=semantic_categories, + semantic_lightweight=semantic_lightweight, + ) + if resolved.get("status") == "ok": + extension_origin = { + "source": "extension", + "presentation": "Расширение", + "extension": { + "guid": extension.get("guid") or public_extension_guid or None, + "name": extension.get("name"), + }, + "status": "ok", + } + resolved = dict(resolved) + resolved_object = dict(resolved.get("object") or {}) + resolved_object["origin"] = extension_origin + resolved["object"] = resolved_object + resolved["matches"] = [ + {**dict(item), "origin": extension_origin} + for item in resolved.get("matches") or [] + if isinstance(item, dict) + ] + return resolved if direct_file_name: if Path(direct_file_name).name != direct_file_name: return invalid_argument("metadata.object.get", "file_name", "file_name must be a safe storage file name.") @@ -6654,6 +7349,19 @@ def get_object( cache_hit = None if cache_hit: direct = metadata_cache_public_row(cache_hit) + # Cache entries may be incomplete after a configuration change and a + # synonym can collide with an actual technical 1C name. A caller's + # exact public name must always win over a synonym match. + if normalize(str(direct.get("name") or "")) != normalize(wanted_name): + exact_probe = list_objects( + wanted_kind, base_id=resolved_base_id, limit=50, offset=0, + include_storage=False, refresh_cache=True, table=storage_table, + name_filter=wanted_name, + ) + exact_match = next((item for item in (exact_probe.get("objects") or []) if isinstance(item, dict) + and normalize(str(item.get("name") or "")) == normalize(wanted_name)), None) + if exact_match: + direct = dict(exact_match) semantic = None if include_semantic: records, error = live_dbnames_records(resolved_base_id, timeout_seconds=timeout_seconds) @@ -6704,7 +7412,9 @@ def get_object( "counts": {"matches": 1, "extension_overlays": 0, "cache_hit": 1, "scanned": 0}, "diagnostics": {"note": "Object identity was resolved from the local metadata cache."}, } - records, error = live_dbnames_records(resolved_base_id, timeout_seconds=timeout_seconds) + if remaining_seconds() <= 0: + return deadline_error("metadata.object.get.dbnames") + records, error = live_dbnames_records(resolved_base_id, timeout_seconds=max(1, remaining_seconds())) if error: result = dict(error) result["method"] = "metadata.object.get" @@ -6749,7 +7459,11 @@ def get_object( } ) if storage_table in {"Config", "ConfigSave"} and kind_request_needs_root_discovery(wanted_kind, requested_public): - root_rows, _root_diagnostics = live_base_root_metadata_index(resolved_base_id, table=storage_table, timeout_seconds=timeout_seconds) + if remaining_seconds() <= 0: + return deadline_error("metadata.object.get.root_discovery") + root_rows, _root_diagnostics = live_base_root_metadata_index( + resolved_base_id, table=storage_table, timeout_seconds=max(1, remaining_seconds()) + ) merge_root_metadata_candidates(candidates, root_rows, wanted_kind=wanted_kind, requested_public=requested_public) matches = [] candidate_rows = list(candidates.values()) @@ -6818,6 +7532,8 @@ def get_object( } scanned = 0 for start in range(0, len(candidate_rows), 80): + if remaining_seconds() <= 0: + return deadline_error("metadata.object.get.candidate_scan", scanned=scanned) chunk = candidate_rows[start : start + 80] payloads: dict[str, bytes] = {} for chunk_table in sorted({preferred_object_storage_table(row, storage_table) for row in chunk}): @@ -6826,7 +7542,7 @@ def get_object( resolved_base_id, chunk_table, [row["guid"] for row in table_rows], - timeout_seconds=timeout_seconds, + timeout_seconds=max(1, remaining_seconds()), ) if payload_error: result = dict(payload_error) @@ -6862,7 +7578,7 @@ def get_object( break matches.sort(key=lambda row: (-float(row["score"]), normalize(row.get("name") or ""), row.get("guid") or "")) canonical = next((row for row in matches if row["score"] >= 0.95), None) or (matches[0] if matches else None) - if not canonical and wanted_kind in {"DataProcessor", "Report"} and wanted_name and not is_guid_text(wanted_name): + if not canonical and storage_table == "ConfigCAS" and wanted_kind in {"DataProcessor", "Report"} and wanted_name and not is_guid_text(wanted_name): extension_matches, _ = metadata_extension_definition_matches( base_id=resolved_base_id, query=wanted_name, @@ -10227,7 +10943,9 @@ def infobase_user_admin_config_for_base(base_id: str) -> tuple[dict[str, Any] | def infobase_user_password_unauthenticated_test_mode() -> bool: - return truthy(os.environ.get("ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED")) + # The isolated adapter test profile already has an explicit global gate. + # Keep the narrower legacy flag for deployments that enable only user admin. + return truthy(os.environ.get("ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED")) or truthy(os.environ.get("ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN")) def infobase_user_password_capabilities(payload: dict[str, Any]) -> dict[str, Any]: @@ -10275,7 +10993,7 @@ def infobase_user_password_capabilities(payload: dict[str, Any]) -> dict[str, An **({"clear": sql_config_error} if sql_config_error else {}), }, **( - {"blocker": {"status": "service_auth_required", "message": "Set ONEC_ADAPTER_SERVICE_TOKEN before enabling password mutations; anonymous password writes are always blocked."}} + {"blocker": {"status": "service_auth_required", "message": "Set ONEC_ADAPTER_SERVICE_TOKEN, or explicitly enable the isolated unauthenticated test profile."}} if not access_ready else {} ), @@ -10689,7 +11407,7 @@ def infobase_user_password_change(payload: dict[str, Any], *, operation: str) -> "base_id": base_id, "operation": operation, "error": "service_auth_required", - "diagnostics": {"message": "Anonymous password mutations are forbidden. Configure ONEC_ADAPTER_SERVICE_TOKEN and the matching MCP backend token first."}, + "diagnostics": {"message": "Configure ONEC_ADAPTER_SERVICE_TOKEN, or explicitly enable the isolated unauthenticated test profile."}, } selector = str(payload.get("user") or payload.get("name") or payload.get("id") or "").strip() confirm_user_id = re.sub(r"[^0-9a-f]", "", str(payload.get("confirm_user_id") or "").casefold()) @@ -11826,6 +12544,7 @@ def cache_connection() -> sqlite3.Connection: conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA busy_timeout=30000") conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_identity_cache ( @@ -12098,6 +12817,75 @@ def cache_connection() -> sqlite3.Connection: ) conn.execute("CREATE INDEX IF NOT EXISTS idx_code_vector_module ON metadata_code_vector_cache(server_key, database_name, module_ref)") conn.execute("CREATE INDEX IF NOT EXISTS idx_code_vector_model ON metadata_code_vector_cache(server_key, database_name, embedding_model)") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS metadata_code_embedding_cache ( + server_key TEXT NOT NULL, + database_name TEXT NOT NULL, + chunk_id TEXT NOT NULL, + module_ref TEXT NOT NULL, + text_sha1 TEXT NOT NULL, + payload_sha1 TEXT NOT NULL, + embedding_model TEXT NOT NULL, + dimensions INTEGER NOT NULL, + embedding_json TEXT NOT NULL, + updated_at REAL NOT NULL, + last_seen_at REAL NOT NULL, + PRIMARY KEY (server_key, database_name, chunk_id, embedding_model) + ) + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_code_embedding_model ON metadata_code_embedding_cache(server_key, database_name, embedding_model, dimensions)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_code_embedding_module ON metadata_code_embedding_cache(server_key, database_name, module_ref)") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS metadata_code_index_state ( + server_key TEXT NOT NULL, + database_name TEXT NOT NULL, + source_revision INTEGER NOT NULL, + dirty INTEGER NOT NULL, + dirty_reason TEXT, + dirty_since REAL, + last_indexed_at REAL, + updated_at REAL NOT NULL, + PRIMARY KEY (server_key, database_name) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS metadata_code_index_outbox ( + server_key TEXT NOT NULL, + database_name TEXT NOT NULL, + event_id TEXT NOT NULL, + source_revision INTEGER NOT NULL, + event_kind TEXT NOT NULL, + source_table TEXT, + file_name TEXT, + reason TEXT, + status TEXT NOT NULL, + created_at REAL NOT NULL, + processed_at REAL, + PRIMARY KEY (server_key, database_name, event_id) + ) + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_code_index_outbox_status ON metadata_code_index_outbox(server_key, database_name, status, source_table, file_name)") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS metadata_code_overlay_tombstone ( + server_key TEXT NOT NULL, + database_name TEXT NOT NULL, + source_table TEXT NOT NULL, + file_name TEXT NOT NULL, + reason TEXT, + source_revision INTEGER, + created_at REAL NOT NULL, + PRIMARY KEY (server_key, database_name, source_table, file_name) + ) + """ + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_code_overlay_tombstone_file ON metadata_code_overlay_tombstone(server_key, database_name, file_name)") conn.execute( """ CREATE TABLE IF NOT EXISTS metadata_write_history ( @@ -12281,19 +13069,21 @@ def metadata_cache_list_rows( def metadata_cache_public_row(row: dict[str, Any]) -> dict[str, Any]: + name = best_text_variant(row.get("name")) + synonym = best_text_variant(row.get("synonym")) if row.get("synonym") else None result = { "guid": row.get("guid"), "kind": row.get("kind"), "kind_ru": row.get("kind_ru"), "public_kind": row.get("public_kind"), - "name": row.get("name"), - "synonym": row.get("synonym"), + "name": name, + "synonym": synonym, "source": row.get("source") or "base", "status": "ok", "identity": { "guid": row.get("guid"), - "name": row.get("name"), - **({"synonyms": {"ru": row.get("synonym")}} if row.get("synonym") else {}), + "name": name, + **({"synonyms": {"ru": synonym}} if synonym else {}), }, "score": 1.0, "match_by": "cache", @@ -12834,7 +13624,34 @@ def decoded_artifact_cache_lookup(config: dict[str, str] | None, *, artifact_kin artifact.setdefault("artifact_cache", {"status": "hit", "content_sha1": sha1}) if row["semantic_text"]: artifact.setdefault("semantic_text", row["semantic_text"]) - return artifact + return artifact + + +def decoded_artifact_cache_lookup_source_prefix( + config: dict[str, str] | None, *, artifact_kind: str, source_table: str, source_file_prefix: str, +) -> dict[str, Any] | None: + """Find a prior decoded candidate route; callers must still live-SHA verify it.""" + if not config or not source_file_prefix: + return None + with cache_connection() as conn: + row = conn.execute( + """ + SELECT source_file, content_sha1, artifact_json + FROM decoded_artifact_cache + WHERE server_key=? AND database_name=? AND artifact_kind=? AND source_table=? AND source_file LIKE ? + ORDER BY last_seen_at DESC LIMIT 1 + """, + (cache_server_key(config), cache_database_name(config), artifact_kind, source_table, f"{source_file_prefix}%"), + ).fetchone() + if not row: + return None + try: + artifact = json.loads(str(row["artifact_json"] or "{}")) + except (TypeError, ValueError): + return None + if not isinstance(artifact, dict): + return None + return {"source_file": str(row["source_file"] or ""), "content_sha1": str(row["content_sha1"] or ""), "artifact": artifact} return None @@ -13103,8 +13920,250 @@ CODE_INDEX_METHODS = { "metadata.code_index.search", "metadata.code_index.verify", "metadata.code_index.refresh_changed", + "metadata.code_index.sync_pending", + "metadata.code_index.poll_changes", + "metadata.code_vector.pending", + "metadata.code_vector.embedding.upsert", "metadata.code_vector.search", } +CODE_INDEX_SAVED_OVERLAYS = { + "Config": "ConfigSave", + "ConfigCAS": "ConfigCASSave", +} +CODE_INDEX_ACTIVE_TABLES = {saved: active for active, saved in CODE_INDEX_SAVED_OVERLAYS.items()} + + +def code_index_state_status(config: dict[str, str]) -> dict[str, Any]: + server_key = cache_server_key(config) + database_name = cache_database_name(config) + now = time.time() + with cache_connection() as conn: + conn.execute( + """ + INSERT OR IGNORE INTO metadata_code_index_state ( + server_key, database_name, source_revision, dirty, updated_at + ) VALUES (?, ?, 0, 0, ?) + """, + (server_key, database_name, now), + ) + state = conn.execute( + """ + SELECT source_revision, dirty, dirty_reason, dirty_since, last_indexed_at, updated_at + FROM metadata_code_index_state + WHERE server_key=? AND database_name=? + """, + (server_key, database_name), + ).fetchone() + pending = conn.execute( + """ + SELECT COUNT(*) AS count + FROM metadata_code_index_outbox + WHERE server_key=? AND database_name=? AND status='pending' + """, + (server_key, database_name), + ).fetchone() + index_stats = conn.execute( + """ + SELECT COUNT(*) AS count, MAX(updated_at) AS newest_updated_at + FROM metadata_code_index_cache + WHERE server_key=? AND database_name=? + """, + (server_key, database_name), + ).fetchone() + source_revision = int((state or {})["source_revision"] or 0) + dirty = bool((state or {})["dirty"]) or int((pending or {})["count"] or 0) > 0 + token_payload = "|".join( + [ + server_key, + database_name, + str(source_revision), + "dirty" if dirty else "clean", + str(int((index_stats or {})["count"] or 0)), + str((index_stats or {})["newest_updated_at"] or ""), + ] + ) + revision_token = f"code-index:{hashlib.sha256(token_payload.encode('utf-8')).hexdigest()}" + return { + "status": "dirty" if dirty else "clean", + "source_revision": source_revision, + "revision_token": revision_token, + "snapshot_token": None if dirty else revision_token, + "pending_events": int((pending or {})["count"] or 0), + "dirty_reason": (state or {})["dirty_reason"], + "dirty_since": (state or {})["dirty_since"], + "last_indexed_at": (state or {})["last_indexed_at"], + } + + +def code_index_mark_dirty( + config: dict[str, str], + *, + reason: str, + source_table: str | None = None, + file_names: list[str] | None = None, + event_kind: str = "saved_state_changed", +) -> dict[str, Any]: + server_key = cache_server_key(config) + database_name = cache_database_name(config) + now = time.time() + normalized_files = sorted({str(item or "").strip() for item in (file_names or []) if str(item or "").strip()}) + event_files: list[str | None] = normalized_files or [None] + with cache_connection() as conn: + conn.execute( + """ + INSERT OR IGNORE INTO metadata_code_index_state ( + server_key, database_name, source_revision, dirty, updated_at + ) VALUES (?, ?, 0, 0, ?) + """, + (server_key, database_name, now), + ) + conn.execute( + """ + UPDATE metadata_code_index_state + SET source_revision=source_revision+1, dirty=1, dirty_reason=?, + dirty_since=COALESCE(dirty_since, ?), updated_at=? + WHERE server_key=? AND database_name=? + """, + (reason, now, now, server_key, database_name), + ) + revision_row = conn.execute( + """ + SELECT source_revision + FROM metadata_code_index_state + WHERE server_key=? AND database_name=? + """, + (server_key, database_name), + ).fetchone() + source_revision = int((revision_row or {})["source_revision"] or 0) + for file_name in event_files: + conn.execute( + """ + INSERT INTO metadata_code_index_outbox ( + server_key, database_name, event_id, source_revision, event_kind, + source_table, file_name, reason, status, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?) + """, + ( + server_key, + database_name, + uuid.uuid4().hex, + source_revision, + event_kind, + source_table, + file_name, + reason, + now, + ), + ) + return code_index_state_status(config) + + +def code_index_mark_indexed( + config: dict[str, str], + *, + source_table: str | None = None, + file_names: list[str] | None = None, + complete_table: bool = False, +) -> dict[str, Any]: + server_key = cache_server_key(config) + database_name = cache_database_name(config) + now = time.time() + normalized_files = sorted({str(item or "").strip() for item in (file_names or []) if str(item or "").strip()}) + with cache_connection() as conn: + conditions = ["server_key=?", "database_name=?", "status='pending'"] + params: list[Any] = [server_key, database_name] + if source_table: + conditions.append("source_table=?") + params.append(source_table) + if normalized_files and not complete_table: + placeholders = ",".join("?" for _ in normalized_files) + conditions.append(f"file_name IN ({placeholders})") + params.extend(normalized_files) + elif not complete_table and not normalized_files: + conditions.append("1=0") + conn.execute( + f""" + UPDATE metadata_code_index_outbox + SET status='processed', processed_at=? + WHERE {' AND '.join(conditions)} + """, + (now, *params), + ) + pending = conn.execute( + """ + SELECT COUNT(*) AS count + FROM metadata_code_index_outbox + WHERE server_key=? AND database_name=? AND status='pending' + """, + (server_key, database_name), + ).fetchone() + dirty = int((pending or {})["count"] or 0) > 0 + conn.execute( + """ + INSERT OR IGNORE INTO metadata_code_index_state ( + server_key, database_name, source_revision, dirty, updated_at + ) VALUES (?, ?, 0, 0, ?) + """, + (server_key, database_name, now), + ) + conn.execute( + """ + UPDATE metadata_code_index_state + SET dirty=?, dirty_reason=CASE WHEN ?=0 THEN NULL ELSE dirty_reason END, + dirty_since=CASE WHEN ?=0 THEN NULL ELSE dirty_since END, + last_indexed_at=?, updated_at=? + WHERE server_key=? AND database_name=? + """, + (1 if dirty else 0, 1 if dirty else 0, 1 if dirty else 0, now, now, server_key, database_name), + ) + return code_index_state_status(config) + + +def code_index_overlay_tombstone_set( + config: dict[str, str], + *, + source_table: str, + file_name: str, + reason: str, +) -> None: + if source_table not in CODE_INDEX_ACTIVE_TABLES or not file_name: + return + state = code_index_state_status(config) + with cache_connection() as conn: + conn.execute( + """ + INSERT INTO metadata_code_overlay_tombstone ( + server_key, database_name, source_table, file_name, + reason, source_revision, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(server_key, database_name, source_table, file_name) DO UPDATE SET + reason=excluded.reason, + source_revision=excluded.source_revision, + created_at=excluded.created_at + """, + ( + cache_server_key(config), + cache_database_name(config), + source_table, + file_name, + reason, + state.get("source_revision"), + time.time(), + ), + ) + + +def code_index_overlay_tombstone_clear(config: dict[str, str], *, source_table: str, file_name: str) -> None: + if source_table not in CODE_INDEX_ACTIVE_TABLES or not file_name: + return + with cache_connection() as conn: + conn.execute( + """ + DELETE FROM metadata_code_overlay_tombstone + WHERE server_key=? AND database_name=? AND source_table=? AND file_name=? + """, + (cache_server_key(config), cache_database_name(config), source_table, file_name), + ) def code_text_sha1(text: str) -> str: @@ -13161,6 +14220,8 @@ def code_module_owner_from_cache(config: dict[str, str] | None, module_ref: str) module_owner = metadata_module_owner_cache_lookup(config, base_module_ref) if module_owner: owner = module_owner.get("owner") or {} + module_payload = module_owner.get("module_payload") if isinstance(module_owner.get("module_payload"), dict) else {} + extension = module_payload.get("extension") if isinstance(module_payload.get("extension"), dict) else {} module_table, module_file_name, _ = parse_module_id(module_ref) saved_extension_guid = "" if module_table == "ConfigCASSave" and "__" in str(module_file_name or ""): @@ -13170,8 +14231,8 @@ def code_module_owner_from_cache(config: dict[str, str] | None, module_ref: str) "owner_name": owner.get("name"), "owner_guid": owner.get("guid"), "form_name": None, - "extension_guid": saved_extension_guid if is_guid_text(saved_extension_guid) else None, - "extension_name": None, + "extension_guid": extension.get("guid") or (saved_extension_guid if is_guid_text(saved_extension_guid) else None), + "extension_name": extension.get("name"), "bsl_offset": None, } return {} @@ -13195,23 +14256,72 @@ def extract_code_index_text_from_payload(data: bytes, *, module_ref: str, bsl_of return str(text or ""), {"status": extraction.get("status"), "source": "bsl_container", **extraction} -def code_index_row_payload(row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: +def code_index_owner_payload( + config: dict[str, str] | None, + module_ref: str, + item: dict[str, Any], +) -> dict[str, Any]: + owner = { + "kind": item.get("owner_kind"), + "name": item.get("owner_name"), + "guid": item.get("owner_guid"), + "form": item.get("form_name"), + "extension": {"guid": item.get("extension_guid"), "name": item.get("extension_name")}, + } + if not config or all(owner.get(key) for key in ("kind", "name", "guid")): + return owner + cached_owner = code_module_owner_from_cache(config, module_ref) + if not cached_owner: + return owner + for public_key, cache_key in (("kind", "owner_kind"), ("name", "owner_name"), ("guid", "owner_guid"), ("form", "form_name")): + if not owner.get(public_key) and cached_owner.get(cache_key): + owner[public_key] = cached_owner.get(cache_key) + cached_extension_guid = cached_owner.get("extension_guid") + cached_extension_name = cached_owner.get("extension_name") + if cached_extension_guid and not owner["extension"].get("guid"): + owner["extension"]["guid"] = cached_extension_guid + if cached_extension_name and not owner["extension"].get("name"): + owner["extension"]["name"] = cached_extension_name + if any(owner.get(key) for key in ("kind", "name", "guid")): + with cache_connection() as conn: + conn.execute( + """ + UPDATE metadata_code_index_cache + SET owner_kind=COALESCE(owner_kind, ?), + owner_name=COALESCE(owner_name, ?), + owner_guid=COALESCE(owner_guid, ?), + form_name=COALESCE(form_name, ?), + extension_guid=COALESCE(extension_guid, ?), + extension_name=COALESCE(extension_name, ?) + WHERE server_key=? AND database_name=? AND module_ref=? + """, + ( + owner.get("kind"), + owner.get("name"), + owner.get("guid"), + owner.get("form"), + owner["extension"].get("guid"), + owner["extension"].get("name"), + cache_server_key(config), + cache_database_name(config), + module_ref, + ), + ) + return owner + + +def code_index_row_payload(row: sqlite3.Row | dict[str, Any], config: dict[str, str] | None = None) -> dict[str, Any]: item = dict(row) try: routines = json.loads(item.get("routines_json") or "[]") except Exception: routines = [] + owner = code_index_owner_payload(config, str(item.get("module_ref") or ""), item) return { "module_ref": item.get("module_ref"), "table": item.get("source_table"), "file_name": item.get("file_name"), - "owner": { - "kind": item.get("owner_kind"), - "name": item.get("owner_name"), - "guid": item.get("owner_guid"), - "form": item.get("form_name"), - "extension": {"guid": item.get("extension_guid"), "name": item.get("extension_name")}, - }, + "owner": owner, "bsl_offset": item.get("bsl_offset"), "stream_index": item.get("stream_index"), "payload_sha1": item.get("payload_sha1"), @@ -13234,13 +14344,17 @@ def collect_backup_ids(value: Any) -> list[str]: backup_id = item.get("backup_id") if isinstance(backup_id, str) and re.fullmatch(r"[0-9a-f]{32}", backup_id) and backup_id not in found: found.append(backup_id) - for child in item.values(): - walk(child) + backup_ids = item.get("backup_ids") + if isinstance(backup_ids, list): + for candidate in backup_ids: + if isinstance(candidate, str) and re.fullmatch(r"[0-9a-f]{32}", candidate) and candidate not in found: + found.append(candidate) + for key, child in item.items(): + if key not in {"backup_id", "backup_ids"}: + walk(child) elif isinstance(item, list): for child in item: walk(child) - elif isinstance(item, str) and re.fullmatch(r"[0-9a-f]{32}", item) and item not in found: - found.append(item) walk(value) return found @@ -13314,6 +14428,36 @@ def metadata_write_history_record(base_id: str, method: str, result: dict[str, A return operation_id +def metadata_write_history_referenced_backup_ids(base_id: str) -> set[str] | None: + config, _error = sql_config_for_base(base_id) + if not config: + return None + referenced: set[str] = set() + try: + with cache_connection() as conn: + rows = conn.execute( + """ + SELECT backup_ids_json + FROM metadata_write_history + WHERE server_key=? AND database_name=? AND base_id=? + """, + (cache_server_key(config), cache_database_name(config), base_id), + ).fetchall() + except Exception: + return None + for row in rows: + try: + backup_ids = json.loads(str(row["backup_ids_json"] or "[]")) + except (TypeError, ValueError, json.JSONDecodeError): + continue + if not isinstance(backup_ids, list): + continue + for backup_id in backup_ids: + if isinstance(backup_id, str) and re.fullmatch(r"[0-9a-f]{32}", backup_id): + referenced.add(backup_id) + return referenced + + def attach_write_history_operation(payload: dict[str, Any], method: str, result: Any) -> Any: if method not in WRITE_HISTORY_RECORDED_METHODS or not isinstance(result, dict): return result @@ -13542,10 +14686,24 @@ def code_index_prune_file_modules( "DELETE FROM metadata_code_vector_cache WHERE server_key=? AND database_name=? AND module_ref=?", (cache_server_key(config), cache_database_name(config), module_ref), ) + conn.execute( + "DELETE FROM metadata_code_embedding_cache WHERE server_key=? AND database_name=? AND module_ref=?", + (cache_server_key(config), cache_database_name(config), module_ref), + ) conn.execute( "DELETE FROM metadata_code_index_cache WHERE server_key=? AND database_name=? AND module_ref=?", (cache_server_key(config), cache_database_name(config), module_ref), ) + if table in CODE_INDEX_ACTIVE_TABLES: + if keep: + code_index_overlay_tombstone_clear(config, source_table=table, file_name=file_name) + else: + code_index_overlay_tombstone_set( + config, + source_table=table, + file_name=file_name, + reason="saved_overlay_contains_no_indexable_bsl", + ) return len(stale) @@ -13568,6 +14726,30 @@ def code_index_upsert( text_sha1 = code_text_sha1(text) routines = public_routine_blocks(text) owner = owner or {} + if table in CODE_INDEX_ACTIVE_TABLES and not any( + owner.get(key) for key in ("owner_kind", "owner_name", "owner_guid", "form_name") + ): + active_table = CODE_INDEX_ACTIVE_TABLES[table] + with cache_connection() as conn: + active_owner = conn.execute( + """ + SELECT owner_kind, owner_name, owner_guid, form_name, extension_guid, extension_name + FROM metadata_code_index_cache + WHERE server_key=? AND database_name=? AND source_table=? AND file_name=? + ORDER BY updated_at DESC + LIMIT 1 + """, + (cache_server_key(config), cache_database_name(config), active_table, file_name), + ).fetchone() + if active_owner: + owner = { + "owner_kind": active_owner["owner_kind"], + "owner_name": active_owner["owner_name"], + "owner_guid": active_owner["owner_guid"], + "form_name": active_owner["form_name"], + "extension_guid": active_owner["extension_guid"], + "extension_name": active_owner["extension_name"], + } with cache_connection() as conn: conn.execute( """ @@ -13585,12 +14767,12 @@ def code_index_upsert( ON CONFLICT(server_key, database_name, module_ref) DO UPDATE SET source_table=excluded.source_table, file_name=excluded.file_name, - owner_kind=excluded.owner_kind, - owner_name=excluded.owner_name, - owner_guid=excluded.owner_guid, - form_name=excluded.form_name, - extension_guid=excluded.extension_guid, - extension_name=excluded.extension_name, + owner_kind=COALESCE(excluded.owner_kind, metadata_code_index_cache.owner_kind), + owner_name=COALESCE(excluded.owner_name, metadata_code_index_cache.owner_name), + owner_guid=COALESCE(excluded.owner_guid, metadata_code_index_cache.owner_guid), + form_name=COALESCE(excluded.form_name, metadata_code_index_cache.form_name), + extension_guid=COALESCE(excluded.extension_guid, metadata_code_index_cache.extension_guid), + extension_name=COALESCE(excluded.extension_name, metadata_code_index_cache.extension_name), bsl_offset=excluded.bsl_offset, stream_index=excluded.stream_index, payload_sha1=excluded.payload_sha1, @@ -13628,6 +14810,8 @@ def code_index_upsert( "last_seen_at": now, }, ) + if table in CODE_INDEX_ACTIVE_TABLES: + code_index_overlay_tombstone_clear(config, source_table=table, file_name=file_name) return { "module_ref": module_ref, "payload_sha1": payload_sha1, @@ -13662,6 +14846,35 @@ def code_vector_upsert_chunks(config: dict[str, str], row: dict[str, Any]) -> in now = time.time() count = 0 with cache_connection() as conn: + conn.execute( + """ + DELETE FROM metadata_code_vector_cache + WHERE server_key=? AND database_name=? AND module_ref=? + """, + (cache_server_key(config), cache_database_name(config), module_ref), + ) + conn.execute( + """ + UPDATE metadata_code_embedding_cache + SET payload_sha1=?, last_seen_at=? + WHERE server_key=? AND database_name=? AND module_ref=? AND text_sha1=? + """, + ( + row.get("payload_sha1"), + now, + cache_server_key(config), + cache_database_name(config), + module_ref, + row.get("text_sha1"), + ), + ) + conn.execute( + """ + DELETE FROM metadata_code_embedding_cache + WHERE server_key=? AND database_name=? AND module_ref=? AND text_sha1<>? + """, + (cache_server_key(config), cache_database_name(config), module_ref, row.get("text_sha1")), + ) for chunk in chunks: chunk_text = str(chunk.get("text") or "") if not chunk_text.strip(): @@ -13949,13 +15162,38 @@ def metadata_code_index_status(payload: dict[str, Any]) -> dict[str, Any]: """, (cache_server_key(config), cache_database_name(config)), ).fetchall() + tombstones = conn.execute( + """ + SELECT COUNT(*) AS count + FROM metadata_code_overlay_tombstone + WHERE server_key=? AND database_name=? + """, + (cache_server_key(config), cache_database_name(config)), + ).fetchone() + embedding_models = conn.execute( + """ + SELECT embedding_model, dimensions, COUNT(*) AS count, + MIN(updated_at) AS oldest_updated_at, MAX(updated_at) AS newest_updated_at + FROM metadata_code_embedding_cache + WHERE server_key=? AND database_name=? + GROUP BY embedding_model, dimensions + ORDER BY embedding_model + """, + (cache_server_key(config), cache_database_name(config)), + ).fetchall() + index_state = code_index_state_status(config) return { "schema": "onec_code_index_status.v1", "method": method, "status": "ok", "base_id": base_id_or_error, "source": {"kind": "code_index_cache", "authoritative": False}, - "counts": {"modules": int((total or {})["count"] or 0), "vector_chunks": int((vectors or {})["count"] or 0)}, + "counts": { + "modules": int((total or {})["count"] or 0), + "vector_chunks": int((vectors or {})["count"] or 0), + "overlay_tombstones": int((tombstones or {})["count"] or 0), + "pending_events": int(index_state.get("pending_events") or 0), + }, "tables": [ { "table": row["source_table"], @@ -13965,10 +15203,22 @@ def metadata_code_index_status(payload: dict[str, Any]) -> dict[str, Any]: } for row in by_table ], + "embedding_models": [ + { + "model": row["embedding_model"], + "dimensions": int(row["dimensions"] or 0), + "chunks": int(row["count"] or 0), + "oldest_updated_at": row["oldest_updated_at"], + "newest_updated_at": row["newest_updated_at"], + } + for row in embedding_models + ], "freshness": { - "status": "cache_status_only", + "status": "dirty" if index_state.get("status") == "dirty" else "cache_status_only", "validation_required": True, "message": "SQL remains authoritative; cache status does not prove individual modules are current.", + "index_state": index_state, + "snapshot_token": index_state.get("snapshot_token"), }, } @@ -14229,6 +15479,19 @@ def metadata_code_index_build(payload: dict[str, Any]) -> dict[str, Any]: pruned += code_index_prune_file_modules(config, table=table, file_name=file_name, keep_module_refs=indexed_module_refs) if not indexed_this_file: skipped += 1 + processed_file_names = file_names[: int(max_items or 500)] + complete_table = bool( + not has_object_selector + and not prefix + and len(file_names) < int(scan_limit or max_items or 500) + and len(processed_file_names) == len(file_names) + ) + index_state = code_index_mark_indexed( + config, + source_table=table, + file_names=processed_file_names, + complete_table=complete_table, + ) return { "schema": "onec_code_index_build.v1", "method": method, @@ -14238,7 +15501,12 @@ def metadata_code_index_build(payload: dict[str, Any]) -> dict[str, Any]: "query": {"table": table, "prefix": prefix or None, "kind": payload.get("kind"), "name": payload.get("name"), "guid": payload.get("guid"), "extension_guid": extension_guid or None, "max_items": int(max_items or 500), "scan_limit": int(scan_limit or 500), "include_vectors": bool(include_vectors)}, **({"object": object_card} if object_card else {}), "counts": {"indexed": indexed, "skipped": skipped, "pruned": pruned, "vector_chunks": vectors, "errors": len(errors), "scanned_files": min(len(file_names), int(max_items or 500)), "discovered_module_refs": len(set(discovered_module_refs)), "forms_scanned": forms_scanned, "form_modules_discovered": form_modules_discovered, "empty_form_modules": empty_form_modules, "form_module_errors": form_module_errors}, - "freshness": {"status": "live_sql_verified", "verified_against_sql": True}, + "freshness": { + "status": "live_sql_verified", + "verified_against_sql": True, + "index_state": index_state, + "snapshot_token": index_state.get("snapshot_token"), + }, "diagnostics": errors[:10], } @@ -14255,6 +15523,9 @@ def metadata_code_index_verify(payload: dict[str, Any]) -> dict[str, Any]: module_ref = str(payload.get("module_ref") or payload.get("module_id") or "").strip() if not module_ref: return invalid_argument(method, "module_ref", "module_ref is required.") + refresh_stale, refresh_stale_error = strict_bool_argument(payload, "refresh_stale", method=method, default=False) + if refresh_stale_error: + return refresh_stale_error table, file_name, stream_index = parse_module_id(module_ref) if not table or not file_name: return invalid_argument(method, "module_ref", MODULE_READ_SELECTOR_OR_MODULE_ID_MESSAGE) @@ -14280,9 +15551,16 @@ def metadata_code_index_verify(payload: dict[str, Any]) -> dict[str, Any]: ).fetchone() if not row: return {"schema": "onec_code_index_verify.v1", "method": method, "status": "not_found", "error": "not_found", "base_id": base_id, "module_ref": module_ref} - cached = code_index_row_payload(row) + cached = code_index_row_payload(row, config) data, _read_config, error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=int(payload.get("timeout_seconds") or 30)) if error: + code_index_mark_dirty( + config, + reason="live_source_read_failed", + source_table=table, + file_names=[file_name], + event_kind="source_verification_failed", + ) return {"schema": "onec_code_index_verify.v1", "method": method, "status": "source_missing", "error": "source_missing", "base_id": base_id, "module_ref": module_ref, "diagnostics": error.get("diagnostics")} current_payload_sha1 = hashlib.sha1(data).hexdigest() text, extraction = current_code_index_text_from_sql( @@ -14297,17 +15575,61 @@ def metadata_code_index_verify(payload: dict[str, Any]) -> dict[str, Any]: current_text_sha1 = code_text_sha1(text) fresh = current_payload_sha1 == cached.get("payload_sha1") and current_text_sha1 == cached.get("text_sha1") now = time.time() + refreshed = False + refreshed_vector_chunks = 0 with cache_connection() as conn: if fresh: conn.execute( "UPDATE metadata_code_index_cache SET last_verified_at=?, last_seen_at=? WHERE server_key=? AND database_name=? AND module_ref=?", (now, now, cache_server_key(config), cache_database_name(config), cached["module_ref"]), ) + if not fresh: + code_index_mark_dirty( + config, + reason="live_source_hash_changed", + source_table=table, + file_names=[file_name], + event_kind="external_source_changed", + ) + if refresh_stale and text.strip() and extraction.get("status") == "ok": + cached_owner = cached.get("owner") if isinstance(cached.get("owner"), dict) else {} + cached_extension = cached_owner.get("extension") if isinstance(cached_owner.get("extension"), dict) else {} + indexed = code_index_upsert( + config, + base_id=base_id, + table=table, + file_name=file_name, + module_ref=cached["module_ref"], + data=data, + text=text, + owner={ + "owner_kind": cached_owner.get("kind"), + "owner_name": cached_owner.get("name"), + "owner_guid": cached_owner.get("guid"), + "form_name": cached_owner.get("form"), + "extension_guid": cached_extension.get("guid"), + "extension_name": cached_extension.get("name"), + }, + bsl_offset=cached.get("bsl_offset"), + stream_index=cached.get("stream_index"), + verified=True, + ) + refreshed_vector_chunks = code_vector_upsert_chunks( + config, + { + **indexed, + "module_ref": cached["module_ref"], + "text": text, + "routines": indexed.get("routines") or [], + }, + ) + code_index_mark_indexed(config, source_table=table, file_names=[file_name]) + refreshed = True return { "schema": "onec_code_index_verify.v1", "method": method, - "status": "ok" if fresh else "stale", - **({"error": "stale"} if not fresh else {}), + "status": "ok" if fresh else "refreshed" if refreshed else "stale", + **({"error": "stale"} if not fresh and not refreshed else {}), "base_id": base_id, "module_ref": cached["module_ref"], "freshness": { @@ -14317,15 +15639,32 @@ def metadata_code_index_verify(payload: dict[str, Any]) -> dict[str, Any]: "text_sha1": cached.get("text_sha1"), "current_payload_sha1": current_payload_sha1, "current_text_sha1": current_text_sha1, - "status": "cache_hit_verified" if fresh else "cache_hit_stale", + "status": "cache_hit_verified" if fresh else "cache_refreshed_from_sql" if refreshed else "cache_hit_stale", }, "cache": {key: cached.get(key) for key in ("table", "file_name", "owner", "bsl_offset", "routine_count", "updated_at", "last_verified_at")}, "extraction": extraction, + **( + { + "refresh": { + "status": "updated", + "vector_chunks": refreshed_vector_chunks, + "index_state": code_index_state_status(config), + } + } + if refreshed + else {} + ), } def metadata_code_index_search(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.code_index.search" + execution_mode = str(payload.get("execution_mode") or "").strip().casefold() + if execution_mode: + if execution_mode != "job": + return invalid_argument(method, "execution_mode", "execution_mode may only be job when provided.", allowed_values=["job"]) + job_payload = {key: value for key, value in payload.items() if key != "execution_mode"} + return adapter_start_job({"method": method, "payload": job_payload, "timeout_seconds": payload.get("timeout_seconds") or 60}) base_id_or_error = require_base_id(payload, method) if isinstance(base_id_or_error, dict): return base_id_or_error @@ -14336,6 +15675,9 @@ def metadata_code_index_search(payload: dict[str, Any]) -> dict[str, Any]: query = str(payload.get("query") or payload.get("pattern") or "").strip() if not query: return invalid_argument(method, "query", "Передайте непустой query.") + owner_kind, owner_name = parse_object_query(None, str(payload.get("ref") or "").strip()) + owner_kind = canonical_kind(owner_kind) or owner_kind + owner_name = str(owner_name or "").strip() mode = str(payload.get("mode") or "fast").strip().casefold() if mode not in {"fast", "live", "background_refresh"}: return invalid_argument(method, "mode", "mode must be one of: fast, live, background_refresh.", allowed_values=["fast", "live", "background_refresh"]) @@ -14353,69 +15695,214 @@ def metadata_code_index_search(payload: dict[str, Any]) -> dict[str, Any]: verify, verify_error = strict_bool_argument(payload, "verify", method=method, default=True) if verify_error: return verify_error + strict, strict_error = strict_bool_argument(payload, "strict", method=method, default=bool(verify)) + if strict_error: + return strict_error + if strict and not verify: + return invalid_argument(method, "verify", "strict=true requires verify=true.") + overfetch_factor, overfetch_error = parse_int_argument( + payload, + "overfetch_factor", + method=method, + default=5, + minimum=1, + maximum=20, + ) + if overfetch_error: + return overfetch_error + index_state = code_index_state_status(config) + if strict and index_state.get("status") == "dirty": + result = search_modules({**payload, "method": None, "query": query, "resolve_owners": True}) + result["method"] = method + result["fallback_reason"] = "local_code_index_dirty" + result["freshness"] = { + "status": "live_sql_verified", + "verified_against_sql": True, + "index_state": index_state, + "snapshot_token": None, + } + return result + search_deadline = adapter_now() + max(1, int(payload.get("timeout_seconds") or 30)) like = f"%{query}%" - with cache_connection() as conn: - rows = conn.execute( - """ - SELECT * - FROM metadata_code_index_cache - WHERE server_key=? AND database_name=? AND text LIKE ? - ORDER BY last_verified_at DESC, updated_at DESC - LIMIT ? - """, - (cache_server_key(config), cache_database_name(config), like, int(scan_limit or 1000)), - ).fetchall() + + def matching_rows() -> tuple[list[sqlite3.Row], int]: + owner_filter_sql = " AND owner_kind=? AND owner_name=?" if owner_kind and owner_name else "" + owner_filter_values: tuple[Any, ...] = (owner_kind, owner_name) if owner_kind and owner_name else () + with cache_connection() as conn: + rows = conn.execute( + f""" + SELECT * + FROM metadata_code_index_cache + WHERE server_key=? AND database_name=? AND text LIKE ?{owner_filter_sql} + ORDER BY last_verified_at DESC, updated_at DESC + LIMIT ? + """, + (cache_server_key(config), cache_database_name(config), like, *owner_filter_values, int(scan_limit or 1000)), + ).fetchall() + saved_rows = conn.execute( + """ + SELECT source_table, file_name + FROM metadata_code_index_cache + WHERE server_key=? AND database_name=? AND source_table IN ('ConfigSave', 'ConfigCASSave') + GROUP BY source_table, file_name + """, + (cache_server_key(config), cache_database_name(config)), + ).fetchall() + tombstone_rows = conn.execute( + """ + SELECT source_table, file_name + FROM metadata_code_overlay_tombstone + WHERE server_key=? AND database_name=? + """, + (cache_server_key(config), cache_database_name(config)), + ).fetchall() + saved_files = {(str(row["source_table"]), str(row["file_name"])) for row in saved_rows} + tombstones = {(str(row["source_table"]), str(row["file_name"])) for row in tombstone_rows} + filtered: list[sqlite3.Row] = [] + shadowed = 0 + for row in rows: + saved_table = CODE_INDEX_SAVED_OVERLAYS.get(str(row["source_table"] or "")) + if saved_table and ( + (saved_table, str(row["file_name"] or "")) in saved_files + or (saved_table, str(row["file_name"] or "")) in tombstones + ): + shadowed += 1 + continue + filtered.append(row) + return filtered, shadowed + + rows, shadowed_candidates = matching_rows() matches: list[dict[str, Any]] = [] - for row in rows[: int(limit or 20)]: - cached = code_index_row_payload(row) - text = str(cached.get("text") or "") - offset = text.casefold().find(query.casefold()) - snippet = text_snippet(text, query) if offset >= 0 else {"text": text[:300], "offset": None} - freshness = { - "source": "code_index_cache", - "verified_against_sql": False, - "payload_sha1": cached.get("payload_sha1"), - "text_sha1": cached.get("text_sha1"), - "status": "cache_hit_unverified", - } - if verify: - verification = metadata_code_index_verify({"base_id": base_id, "module_ref": cached["module_ref"], "timeout_seconds": payload.get("timeout_seconds", 30)}) - freshness = verification.get("freshness") or freshness - read_selector = { - "method": "modules.read", - "base_id": base_id, - "module_ref": cached["module_ref"], - "preview": True, - "max_chars": int(payload.get("read_max_chars") or 4000), - **({"bsl_offset": cached.get("bsl_offset")} if cached.get("bsl_offset") is not None else {}), - } - matches.append( - { - "score": 1.0, - "snippet": snippet, - "owner": cached.get("owner"), - "module": {"name": "Модуль БСЛ", "routine_count": cached.get("routine_count"), "form": (cached.get("owner") or {}).get("form")}, - "read_selector": read_selector, - "origin": {"source": "code_index_cache", "status": "verified" if freshness.get("status") == "cache_hit_verified" else "candidate"}, - "freshness": freshness, + rejected_stale = 0 + refreshed_modules = 0 + time_budget_exhausted = False + candidate_window = max(int(limit or 20), int(limit or 20) * int(overfetch_factor or 5)) + for search_pass in range(2): + matches = [] + refreshed_this_pass = False + for row in rows[:candidate_window]: + remaining_seconds = int(search_deadline - adapter_now()) + if remaining_seconds <= 0: + time_budget_exhausted = True + break + cached = code_index_row_payload(row, config) + text = str(cached.get("text") or "") + offset = text.casefold().find(query.casefold()) + snippet = text_snippet(text, query) if offset >= 0 else {"text": text[:300], "offset": None} + freshness = { + "source": "code_index_cache", + "verified_against_sql": False, + "payload_sha1": cached.get("payload_sha1"), + "text_sha1": cached.get("text_sha1"), + "status": "cache_hit_unverified", } - ) + if verify: + verification = metadata_code_index_verify( + { + "base_id": base_id, + "module_ref": cached["module_ref"], + "timeout_seconds": max(1, min(5, remaining_seconds)), + "refresh_stale": bool(strict), + } + ) + freshness = verification.get("freshness") or freshness + if freshness.get("status") == "cache_refreshed_from_sql": + refreshed_modules += 1 + refreshed_this_pass = True + continue + if strict and freshness.get("status") != "cache_hit_verified": + rejected_stale += 1 + continue + owner = cached.get("owner") if isinstance(cached.get("owner"), dict) else {} + owner_extension = owner.get("extension") if isinstance(owner.get("extension"), dict) else {} + cached_module_table, _cached_file_name, _cached_stream_index = parse_module_id(str(cached.get("module_ref") or "")) + if not owner.get("name") or (cached_module_table == "ConfigCAS" and not owner_extension.get("name")): + metadata_module_owner_resolve_from_ref( + base_id, + config, + str(cached.get("module_ref") or ""), + timeout_seconds=int(payload.get("timeout_seconds") or 30), + ) + owner = code_index_owner_payload(config, str(cached.get("module_ref") or ""), dict(row)) + owner_extension = owner.get("extension") if isinstance(owner.get("extension"), dict) else {} + public_ref = object_selector_ref(owner.get("kind"), owner.get("name")) + read_selector = { + "method": "modules.read", + "base_id": base_id, + "module_ref": cached["module_ref"], + "preview": True, + "max_chars": int(payload.get("read_max_chars") or 4000), + **({"bsl_offset": cached.get("bsl_offset")} if cached.get("bsl_offset") is not None else {}), + } + matches.append( + { + "score": 1.0, + "snippet": snippet, + "owner": owner, + "object_ref": public_ref or None, + "object_selector": { + **({"kind": owner.get("kind")} if owner.get("kind") else {}), + **({"name": owner.get("name")} if owner.get("name") else {}), + **({"guid": owner.get("guid")} if owner.get("guid") else {}), + **({"extension": owner_extension.get("name")} if owner_extension.get("name") else {}), + **({"extension_guid": owner_extension.get("guid")} if owner_extension.get("guid") else {}), + }, + "module": {"name": "Модуль БСЛ", "routine_count": cached.get("routine_count"), "form": owner.get("form")}, + "read_selector": read_selector, + "origin": {"source": "code_index_cache", "status": "verified" if freshness.get("status") == "cache_hit_verified" else "candidate"}, + "freshness": freshness, + } + ) + if len(matches) >= int(limit or 20): + break + if time_budget_exhausted: + break + if refreshed_this_pass and search_pass == 0: + rows, pass_shadowed = matching_rows() + shadowed_candidates = max(shadowed_candidates, pass_shadowed) + continue + break + index_state = code_index_state_status(config) return { "schema": "onec_code_index_search.v1", "method": method, - "status": "ok" if matches else "not_found", - **({"error": "not_found"} if not matches else {}), + "status": "partial" if time_budget_exhausted else "ok" if matches else "not_found", + **({"error": "not_found"} if not matches and not time_budget_exhausted else {}), "base_id": base_id, "source": {"kind": "code_index_cache", "authoritative": False}, - "query": {"query": query, "mode": mode, "limit": int(limit or 20), "scan_limit": int(scan_limit or 1000), "verify": bool(verify)}, + "query": { + "query": query, + **({"ref": object_selector_ref(owner_kind, owner_name)} if owner_kind and owner_name else {}), + "mode": mode, + "limit": int(limit or 20), + "scan_limit": int(scan_limit or 1000), + "verify": bool(verify), + "strict": bool(strict), + "overfetch_factor": int(overfetch_factor or 5), + }, "matches": matches, - "counts": {"matches": len(matches), "candidates": len(rows)}, + "counts": { + "matches": len(matches), + "candidates": len(rows), + "candidate_window": min(len(rows), candidate_window), + "refreshed_modules": refreshed_modules, + "rejected_stale": rejected_stale, + "shadowed_candidates": shadowed_candidates, + "time_budget_exhausted": time_budget_exhausted, + }, + "freshness": { + "status": "live_verified_candidates" if strict else "candidate_only", + "verified_against_sql": bool(strict), + "index_state": index_state, + "snapshot_token": index_state.get("snapshot_token"), + }, + **({"diagnostics": [{"code": "time_budget_exhausted", "message": "Strict live verification reached the request time budget; returned matches are verified."}]} if time_budget_exhausted else {}), } def metadata_code_index_refresh_changed(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.code_index.refresh_changed" - search = metadata_code_index_search({**payload, "method": None, "query": str(payload.get("query") or ""), "verify": True}) + search = metadata_code_index_search({**payload, "method": None, "query": str(payload.get("query") or ""), "verify": True, "strict": False}) stale = [match for match in search.get("matches") or [] if (match.get("freshness") or {}).get("status") == "cache_hit_stale"] refreshed = 0 for match in stale: @@ -14429,6 +15916,650 @@ def metadata_code_index_refresh_changed(payload: dict[str, Any]) -> dict[str, An return {"schema": "onec_code_index_refresh_changed.v1", "method": method, "status": "ok", "base_id": payload.get("base_id"), "counts": {"stale": len(stale), "refreshed": refreshed}, "search": search} +def code_index_delete_file(config: dict[str, str], *, source_table: str, file_name: str) -> int: + with cache_connection() as conn: + rows = conn.execute( + """ + SELECT module_ref + FROM metadata_code_index_cache + WHERE server_key=? AND database_name=? AND source_table=? AND file_name=? + """, + (cache_server_key(config), cache_database_name(config), source_table, file_name), + ).fetchall() + module_refs = [str(row["module_ref"] or "") for row in rows if str(row["module_ref"] or "")] + for module_ref in module_refs: + conn.execute( + """ + DELETE FROM metadata_code_vector_cache + WHERE server_key=? AND database_name=? AND module_ref=? + """, + (cache_server_key(config), cache_database_name(config), module_ref), + ) + conn.execute( + """ + DELETE FROM metadata_code_embedding_cache + WHERE server_key=? AND database_name=? AND module_ref=? + """, + (cache_server_key(config), cache_database_name(config), module_ref), + ) + cursor = conn.execute( + """ + DELETE FROM metadata_code_index_cache + WHERE server_key=? AND database_name=? AND source_table=? AND file_name=? + """, + (cache_server_key(config), cache_database_name(config), source_table, file_name), + ) + if source_table in CODE_INDEX_ACTIVE_TABLES: + code_index_overlay_tombstone_clear(config, source_table=source_table, file_name=file_name) + return int(cursor.rowcount or 0) + + +def metadata_code_index_sync_pending(payload: dict[str, Any]) -> dict[str, Any]: + method = "metadata.code_index.sync_pending" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + config, config_error = sql_config_for_base(base_id) + if not config: + return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) + limit, limit_error = parse_int_argument(payload, "limit", method=method, default=20, minimum=1, maximum=200) + if limit_error: + return limit_error + max_items, max_items_error = parse_int_argument(payload, "max_items", method=method, default=5000, minimum=1, maximum=50000) + if max_items_error: + return max_items_error + timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=600) + if timeout_error: + return timeout_error + with cache_connection() as conn: + rows = conn.execute( + """ + SELECT event_id, source_revision, event_kind, source_table, file_name, reason, created_at + FROM metadata_code_index_outbox + WHERE server_key=? AND database_name=? AND status='pending' + ORDER BY source_revision, created_at, event_id + LIMIT ? + """, + (cache_server_key(config), cache_database_name(config), int(limit or 20)), + ).fetchall() + processed = 0 + rebuilt_modules = 0 + removed_modules = 0 + errors: list[dict[str, Any]] = [] + events: list[dict[str, Any]] = [] + handled_targets: set[tuple[str, str | None]] = set() + for row in rows: + source_table = str(row["source_table"] or "") + file_name = str(row["file_name"] or "").strip() or None + target = (source_table, file_name) + if target in handled_targets: + continue + handled_targets.add(target) + if source_table not in STORAGE_TABLES: + errors.append( + { + "event_id": row["event_id"], + "status": "unsupported", + "source_table": source_table or None, + "file_name": file_name, + } + ) + continue + build_payload = { + "base_id": base_id, + "table": source_table, + "include_vectors": True, + "timeout_seconds": int(timeout_seconds or 120), + } + if file_name: + build_payload.update({"prefix": file_name, "max_items": 1, "scan_limit": 1}) + else: + build_payload.update({"max_items": int(max_items or 5000), "scan_limit": int(max_items or 5000)}) + build = metadata_code_index_build(build_payload) + build_counts = build.get("counts") if isinstance(build.get("counts"), dict) else {} + event_result = { + "event_id": row["event_id"], + "source_revision": int(row["source_revision"] or 0), + "source_table": source_table, + "file_name": file_name, + "status": build.get("status"), + "counts": build_counts, + } + if build.get("status") == "ok": + scanned_files = int(build_counts.get("scanned_files") or 0) + if file_name and scanned_files == 0: + removed = code_index_delete_file(config, source_table=source_table, file_name=file_name) + removed_modules += removed + code_index_mark_indexed(config, source_table=source_table, file_names=[file_name]) + event_result["status"] = "source_removed" + event_result["removed_modules"] = removed + rebuilt_modules += int(build_counts.get("indexed") or 0) + processed += 1 + else: + errors.append( + { + "event_id": row["event_id"], + "status": build.get("status") or "error", + "source_table": source_table, + "file_name": file_name, + "diagnostics": build.get("diagnostics"), + } + ) + events.append(event_result) + index_state = code_index_state_status(config) + return { + "schema": "onec_code_index_sync_pending.v1", + "method": method, + "status": "ok" if not errors else "partial", + "base_id": base_id, + "source": {"kind": "live_sql", "authoritative": True}, + "events": events, + "counts": { + "selected_events": len(rows), + "processed_targets": processed, + "rebuilt_modules": rebuilt_modules, + "removed_modules": removed_modules, + "errors": len(errors), + "remaining_events": int(index_state.get("pending_events") or 0), + }, + "freshness": { + "status": index_state.get("status"), + "index_state": index_state, + "snapshot_token": index_state.get("snapshot_token"), + }, + "diagnostics": errors[:20], + } + + +def metadata_code_index_poll_changes(payload: dict[str, Any]) -> dict[str, Any]: + method = "metadata.code_index.poll_changes" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + config, config_error = sql_config_for_base(base_id) + if not config: + return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) + include_active, include_active_error = strict_bool_argument(payload, "include_active", method=method, default=False) + if include_active_error: + return include_active_error + max_items, max_items_error = parse_int_argument(payload, "max_items", method=method, default=1000, minimum=1, maximum=50000) + if max_items_error: + return max_items_error + verify_limit, verify_limit_error = parse_int_argument(payload, "verify_limit", method=method, default=200, minimum=1, maximum=5000) + if verify_limit_error: + return verify_limit_error + timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=120, minimum=1, maximum=600) + if timeout_error: + return timeout_error + requested_table = str(payload.get("table") or "").strip() + if requested_table and requested_table not in STORAGE_TABLES: + return invalid_argument(method, "table", "Unsupported storage table.", allowed_values=sorted(STORAGE_TABLES)) + tables = [requested_table] if requested_table else ["ConfigSave", "ConfigCASSave"] + if not requested_table and include_active: + tables = ["Config", "ConfigSave", "ConfigCAS", "ConfigCASSave"] + discovered_new = 0 + discovered_removed = 0 + verified = 0 + refreshed = 0 + tombstones_rechecked = 0 + coverage_complete = True + table_results: list[dict[str, Any]] = [] + diagnostics: list[dict[str, Any]] = [] + for table in tables: + files = storage_files_list( + { + "base_id": base_id, + "table": table, + "limit": int(max_items or 1000), + "timeout_seconds": int(timeout_seconds or 120), + "_internal": True, + } + ) + if files.get("status") != "ok": + coverage_complete = False + diagnostics.append( + { + "table": table, + "status": files.get("status") or "error", + "diagnostics": files.get("diagnostics"), + } + ) + continue + live_files = { + str(row.get("FileName") or "") + for row in files.get("files") or [] + if isinstance(row, dict) and str(row.get("FileName") or "") + } + table_complete = len(live_files) < int(max_items or 1000) + coverage_complete = coverage_complete and table_complete + with cache_connection() as conn: + cached_rows = conn.execute( + """ + SELECT module_ref, file_name + FROM metadata_code_index_cache + WHERE server_key=? AND database_name=? AND source_table=? + ORDER BY last_verified_at ASC, updated_at ASC + """, + (cache_server_key(config), cache_database_name(config), table), + ).fetchall() + tombstone_rows = conn.execute( + """ + SELECT file_name + FROM metadata_code_overlay_tombstone + WHERE server_key=? AND database_name=? AND source_table=? + """, + (cache_server_key(config), cache_database_name(config), table), + ).fetchall() + cached_files = {str(row["file_name"] or "") for row in cached_rows if str(row["file_name"] or "")} + tombstone_files = {str(row["file_name"] or "") for row in tombstone_rows if str(row["file_name"] or "")} + known_files = cached_files | tombstone_files + new_files = sorted(live_files - known_files) + removed_files = sorted(known_files - live_files) if table_complete else [] + if new_files: + code_index_mark_dirty( + config, + reason="poll_discovered_new_source", + source_table=table, + file_names=new_files, + event_kind="external_source_added", + ) + discovered_new += len(new_files) + for file_name in removed_files: + code_index_mark_dirty( + config, + reason="poll_discovered_removed_source", + source_table=table, + file_names=[file_name], + event_kind="external_source_removed", + ) + code_index_delete_file(config, source_table=table, file_name=file_name) + code_index_mark_indexed(config, source_table=table, file_names=[file_name]) + discovered_removed += 1 + verification_rows = [row for row in cached_rows if str(row["file_name"] or "") in live_files] + for row in verification_rows[: int(verify_limit or 200)]: + verification = metadata_code_index_verify( + { + "base_id": base_id, + "module_ref": row["module_ref"], + "timeout_seconds": int(timeout_seconds or 120), + "refresh_stale": True, + } + ) + verified += 1 + if (verification.get("freshness") or {}).get("status") == "cache_refreshed_from_sql": + refreshed += 1 + elif verification.get("status") not in {"ok", "refreshed"}: + diagnostics.append( + { + "table": table, + "module_ref": row["module_ref"], + "status": verification.get("status") or "error", + "diagnostics": verification.get("diagnostics"), + } + ) + # A saved file that previously contained no BSL can later gain code without + # changing its name. Requeue tombstones so the payload is decoded again. + recheck_tombstones = sorted(tombstone_files & live_files) + if recheck_tombstones: + code_index_mark_dirty( + config, + reason="poll_recheck_saved_overlay_tombstone", + source_table=table, + file_names=recheck_tombstones, + event_kind="tombstone_recheck", + ) + tombstones_rechecked += len(recheck_tombstones) + table_results.append( + { + "table": table, + "live_files": len(live_files), + "cached_files": len(cached_files), + "new_files": len(new_files), + "removed_files": len(removed_files), + "tombstones_rechecked": len(recheck_tombstones), + "coverage_complete": table_complete, + } + ) + sync_result = metadata_code_index_sync_pending( + { + "base_id": base_id, + "limit": min(200, max(1, discovered_new + tombstones_rechecked)), + "max_items": int(max_items or 1000), + "timeout_seconds": int(timeout_seconds or 120), + } + ) + index_state = code_index_state_status(config) + return { + "schema": "onec_code_index_poll_changes.v1", + "method": method, + "status": "ok" if not diagnostics else "partial", + "base_id": base_id, + "source": {"kind": "live_sql", "authoritative": True}, + "query": { + "table": requested_table or None, + "include_active": bool(include_active), + "max_items": int(max_items or 1000), + "verify_limit": int(verify_limit or 200), + }, + "tables": table_results, + "counts": { + "new_files": discovered_new, + "removed_files": discovered_removed, + "verified_modules": verified, + "refreshed_modules": refreshed, + "tombstones_rechecked": tombstones_rechecked, + "remaining_events": int(index_state.get("pending_events") or 0), + }, + "coverage": { + "complete": coverage_complete, + "message": ( + "All returned table inventories fit within max_items." + if coverage_complete + else "At least one table reached max_items; increase max_items before treating absence as deletion." + ), + }, + "sync": sync_result, + "freshness": { + "status": index_state.get("status"), + "index_state": index_state, + "snapshot_token": index_state.get("snapshot_token"), + }, + "diagnostics": diagnostics, + } + + +def code_vector_chunk_full_text(row: sqlite3.Row) -> str: + try: + routines = json.loads(row["routines_json"] or "[]") + except Exception: + routines = [] + chunks = code_index_chunk_texts(str(row["module_text"] or ""), routines if isinstance(routines, list) else []) + for chunk in chunks: + if ( + str(chunk.get("chunk_kind") or "") == str(row["chunk_kind"] or "") + and int(chunk.get("chunk_index") or 0) == int(row["chunk_index"] or 0) + and str(chunk.get("routine_name") or "") == str(row["routine_name"] or "") + ): + return str(chunk.get("text") or "") + return str(row["text_preview"] or "") + + +def metadata_code_vector_pending(payload: dict[str, Any]) -> dict[str, Any]: + method = "metadata.code_vector.pending" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + config, config_error = sql_config_for_base(base_id) + if not config: + return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) + embedding_model = str(payload.get("embedding_model") or "").strip() + if not embedding_model: + return invalid_argument(method, "embedding_model", "embedding_model is required.") + limit, limit_error = parse_int_argument(payload, "limit", method=method, default=100, minimum=1, maximum=1000) + if limit_error: + return limit_error + requested_chunk_kinds = payload.get("chunk_kinds") + chunk_kinds: list[str] = [] + if requested_chunk_kinds is not None: + if not isinstance(requested_chunk_kinds, list): + return invalid_argument(method, "chunk_kinds", "chunk_kinds must be a JSON array.") + for value in requested_chunk_kinds: + chunk_kind = str(value or "").strip().lower() + if chunk_kind not in {"module", "routine"}: + return invalid_argument( + method, + "chunk_kinds", + "Unsupported chunk kind.", + allowed_values=["module", "routine"], + ) + if chunk_kind not in chunk_kinds: + chunk_kinds.append(chunk_kind) + if not chunk_kinds: + return invalid_argument(method, "chunk_kinds", "chunk_kinds must not be empty.") + kind_clause = "" + kind_params: list[Any] = [] + if chunk_kinds: + kind_clause = f" AND v.chunk_kind IN ({','.join('?' for _ in chunk_kinds)})" + kind_params.extend(chunk_kinds) + max_text_chars: int | None = None + if payload.get("max_text_chars") not in {None, ""}: + max_text_chars, max_text_chars_error = parse_int_argument( + payload, + "max_text_chars", + method=method, + default=4000, + minimum=100, + maximum=100000, + ) + if max_text_chars_error: + return max_text_chars_error + with cache_connection() as conn: + rows = conn.execute( + f""" + SELECT v.chunk_id, v.module_ref, v.routine_name, v.chunk_kind, v.chunk_index, + v.text_sha1, v.payload_sha1, v.text_preview, + c.text AS module_text, c.routines_json, + c.owner_kind, c.owner_name, c.owner_guid, c.form_name, + c.source_table, c.file_name + FROM metadata_code_vector_cache v + JOIN metadata_code_index_cache c + ON c.server_key=v.server_key AND c.database_name=v.database_name + AND c.module_ref=v.module_ref + AND c.text_sha1=v.text_sha1 + LEFT JOIN metadata_code_embedding_cache e + ON e.server_key=v.server_key AND e.database_name=v.database_name + AND e.chunk_id=v.chunk_id AND e.embedding_model=? + AND e.text_sha1=v.text_sha1 + WHERE v.server_key=? AND v.database_name=? AND e.chunk_id IS NULL + {kind_clause} + ORDER BY CASE v.chunk_kind WHEN 'routine' THEN 0 ELSE 1 END, + v.updated_at ASC, v.chunk_id + LIMIT ? + """, + ( + embedding_model, + cache_server_key(config), + cache_database_name(config), + *kind_params, + min(5000, int(limit or 100) * 5), + ), + ).fetchall() + saved_rows = conn.execute( + """ + SELECT source_table, file_name + FROM metadata_code_index_cache + WHERE server_key=? AND database_name=? AND source_table IN ('ConfigSave', 'ConfigCASSave') + GROUP BY source_table, file_name + """, + (cache_server_key(config), cache_database_name(config)), + ).fetchall() + tombstone_rows = conn.execute( + """ + SELECT source_table, file_name + FROM metadata_code_overlay_tombstone + WHERE server_key=? AND database_name=? + """, + (cache_server_key(config), cache_database_name(config)), + ).fetchall() + saved_files = {(str(row["source_table"]), str(row["file_name"])) for row in saved_rows} + tombstones = {(str(row["source_table"]), str(row["file_name"])) for row in tombstone_rows} + chunks: list[dict[str, Any]] = [] + for row in rows: + saved_table = CODE_INDEX_SAVED_OVERLAYS.get(str(row["source_table"] or "")) + if saved_table and ( + (saved_table, str(row["file_name"] or "")) in saved_files + or (saved_table, str(row["file_name"] or "")) in tombstones + ): + continue + owner = code_index_owner_payload(config, str(row["module_ref"] or ""), dict(row)) + public_ref = object_selector_ref(owner.get("kind"), owner.get("name")) + chunk_text = code_vector_chunk_full_text(row) + if max_text_chars is not None and len(chunk_text) > max_text_chars: + continue + chunks.append( + { + "chunk_id": row["chunk_id"], + "module_ref": row["module_ref"], + "chunk": { + "kind": row["chunk_kind"], + "routine_name": row["routine_name"], + "index": int(row["chunk_index"] or 0), + }, + "object_ref": public_ref or None, + "object_selector": { + **({"kind": owner.get("kind")} if owner.get("kind") else {}), + **({"name": owner.get("name")} if owner.get("name") else {}), + **({"guid": owner.get("guid")} if owner.get("guid") else {}), + }, + "text": chunk_text, + "text_sha1": row["text_sha1"], + "payload_sha1": row["payload_sha1"], + "precondition": { + "chunk_id": row["chunk_id"], + "text_sha1": row["text_sha1"], + "message": "Both values must still match when metadata.code_vector.embedding.upsert is called.", + }, + } + ) + if len(chunks) >= int(limit or 100): + break + index_state = code_index_state_status(config) + return { + "schema": "onec_code_vector_pending.v1", + "method": method, + "status": "ok", + "base_id": base_id, + "embedding_model": embedding_model, + "query": { + "chunk_kinds": chunk_kinds or None, + "max_text_chars": max_text_chars, + "limit": int(limit or 100), + }, + "chunks": chunks, + "counts": {"chunks": len(chunks)}, + "freshness": { + "status": index_state.get("status"), + "index_state": index_state, + "snapshot_token": index_state.get("snapshot_token"), + }, + } + + +def metadata_code_vector_embedding_upsert(payload: dict[str, Any]) -> dict[str, Any]: + method = "metadata.code_vector.embedding.upsert" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + config, config_error = sql_config_for_base(base_id) + if not config: + return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) + chunk_id = str(payload.get("chunk_id") or "").strip() + text_sha1 = str(payload.get("text_sha1") or "").strip().lower() + embedding_model = str(payload.get("embedding_model") or "").strip() + embedding = numeric_vector(payload.get("embedding")) + if not chunk_id: + return invalid_argument(method, "chunk_id", "chunk_id is required.") + if not re.fullmatch(r"[0-9a-f]{40}", text_sha1): + return invalid_argument(method, "text_sha1", "text_sha1 must be a 40-character SHA1 string.") + if not embedding_model: + return invalid_argument(method, "embedding_model", "embedding_model is required.") + if not embedding: + return invalid_argument(method, "embedding", "embedding must be a non-empty JSON array of numbers.") + with cache_connection() as conn: + row = conn.execute( + """ + SELECT v.module_ref, v.text_sha1, v.payload_sha1 + FROM metadata_code_vector_cache v + JOIN metadata_code_index_cache c + ON c.server_key=v.server_key AND c.database_name=v.database_name + AND c.module_ref=v.module_ref AND c.text_sha1=v.text_sha1 + WHERE v.server_key=? AND v.database_name=? AND v.chunk_id=? + LIMIT 1 + """, + (cache_server_key(config), cache_database_name(config), chunk_id), + ).fetchone() + if not row: + return { + "schema": "onec_code_vector_embedding_upsert.v1", + "method": method, + "status": "conflict", + "error": "chunk_missing_or_changed", + "base_id": base_id, + "chunk_id": chunk_id, + } + if str(row["text_sha1"] or "").lower() != text_sha1: + return { + "schema": "onec_code_vector_embedding_upsert.v1", + "method": method, + "status": "conflict", + "error": "text_sha1_changed", + "base_id": base_id, + "chunk_id": chunk_id, + "expected_text_sha1": text_sha1, + "current_text_sha1": row["text_sha1"], + } + dimensions_row = conn.execute( + """ + SELECT dimensions + FROM metadata_code_embedding_cache + WHERE server_key=? AND database_name=? AND embedding_model=? + LIMIT 1 + """, + (cache_server_key(config), cache_database_name(config), embedding_model), + ).fetchone() + if dimensions_row and int(dimensions_row["dimensions"] or 0) != len(embedding): + return invalid_argument( + method, + "embedding", + f"Embedding dimensions must match existing model dimensions ({int(dimensions_row['dimensions'] or 0)}).", + ) + now = time.time() + conn.execute( + """ + INSERT INTO metadata_code_embedding_cache ( + server_key, database_name, chunk_id, module_ref, text_sha1, + payload_sha1, embedding_model, dimensions, embedding_json, + updated_at, last_seen_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(server_key, database_name, chunk_id, embedding_model) DO UPDATE SET + module_ref=excluded.module_ref, + text_sha1=excluded.text_sha1, + payload_sha1=excluded.payload_sha1, + dimensions=excluded.dimensions, + embedding_json=excluded.embedding_json, + updated_at=excluded.updated_at, + last_seen_at=excluded.last_seen_at + """, + ( + cache_server_key(config), + cache_database_name(config), + chunk_id, + row["module_ref"], + text_sha1, + row["payload_sha1"], + embedding_model, + len(embedding), + json.dumps(embedding, ensure_ascii=False, separators=(",", ":")), + now, + now, + ), + ) + return { + "schema": "onec_code_vector_embedding_upsert.v1", + "method": method, + "status": "ok", + "base_id": base_id, + "chunk_id": chunk_id, + "text_sha1": text_sha1, + "embedding_model": embedding_model, + "dimensions": len(embedding), + } + + def metadata_code_vector_search(payload: dict[str, Any]) -> dict[str, Any]: method = "metadata.code_vector.search" base_id_or_error = require_base_id(payload, method) @@ -14439,7 +16570,19 @@ def metadata_code_vector_search(payload: dict[str, Any]) -> dict[str, Any]: if not config: return public_error_result({"status": "source_missing", "diagnostics": config_error}, include_storage=False, method=method) query = str(payload.get("query") or "").strip() - query_embedding = numeric_vector(payload.get("query_embedding")) or (code_hashing_embedding(query) if query else None) + embedding_model = str(payload.get("embedding_model") or CODE_INDEX_VECTOR_MODEL).strip() + supplied_query_embedding = numeric_vector(payload.get("query_embedding")) + if payload.get("query_embedding") is not None and supplied_query_embedding is None: + return invalid_argument(method, "query_embedding", "query_embedding must be a non-empty JSON array of numbers.") + query_embedding = supplied_query_embedding + if query_embedding is None and query and embedding_model == CODE_INDEX_VECTOR_MODEL: + query_embedding = code_hashing_embedding(query) + if query_embedding is None and query and embedding_model != CODE_INDEX_VECTOR_MODEL: + return invalid_argument( + method, + "query_embedding", + "A query_embedding computed by the selected embedding_model is required for external embeddings.", + ) if not query_embedding: return invalid_argument(method, "query", "Pass query or query_embedding.") limit, limit_error = parse_int_alias_argument(payload, "limit", "max_matches", method=method, default=10, minimum=1, maximum=100) @@ -14451,71 +16594,271 @@ def metadata_code_vector_search(payload: dict[str, Any]) -> dict[str, Any]: verify, verify_error = strict_bool_argument(payload, "verify", method=method, default=True) if verify_error: return verify_error - with cache_connection() as conn: - rows = conn.execute( - """ - SELECT v.*, c.owner_kind, c.owner_name, c.owner_guid, c.form_name, c.bsl_offset, c.source_table, c.file_name - FROM metadata_code_vector_cache v - JOIN metadata_code_index_cache c - ON c.server_key=v.server_key AND c.database_name=v.database_name AND c.module_ref=v.module_ref - WHERE v.server_key=? AND v.database_name=? AND v.embedding_model=? - ORDER BY v.updated_at DESC - LIMIT ? - """, - (cache_server_key(config), cache_database_name(config), CODE_INDEX_VECTOR_MODEL, int(scan_limit or 2000)), - ).fetchall() - candidates: list[dict[str, Any]] = [] - for row in rows: - try: - embedding = numeric_vector(json.loads(row["embedding_json"] or "[]")) - except Exception: - embedding = None - score = cosine_similarity(query_embedding, embedding or []) - if score is None: - continue - candidates.append((float(score), row)) - candidates.sort(key=lambda pair: -pair[0]) + strict, strict_error = strict_bool_argument(payload, "strict", method=method, default=bool(verify)) + if strict_error: + return strict_error + if strict and not verify: + return invalid_argument(method, "verify", "strict=true requires verify=true.") + overfetch_factor, overfetch_error = parse_int_argument( + payload, + "overfetch_factor", + method=method, + default=5, + minimum=1, + maximum=20, + ) + if overfetch_error: + return overfetch_error + + def ranked_candidates() -> tuple[list[tuple[float, sqlite3.Row]], int]: + with cache_connection() as conn: + if embedding_model == CODE_INDEX_VECTOR_MODEL: + rows = conn.execute( + """ + SELECT v.chunk_id, v.module_ref, v.routine_name, v.chunk_kind, + v.chunk_index, v.text_sha1, v.payload_sha1, v.text_preview, + v.embedding_json AS search_embedding_json, + c.owner_kind, c.owner_name, c.owner_guid, c.form_name, + c.extension_guid, c.extension_name, c.bsl_offset, + c.source_table, c.file_name + FROM metadata_code_vector_cache v + JOIN metadata_code_index_cache c + ON c.server_key=v.server_key AND c.database_name=v.database_name + AND c.module_ref=v.module_ref AND c.text_sha1=v.text_sha1 + WHERE v.server_key=? AND v.database_name=? AND v.embedding_model=? + ORDER BY v.updated_at DESC + LIMIT ? + """, + (cache_server_key(config), cache_database_name(config), embedding_model, int(scan_limit or 2000)), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT v.chunk_id, v.module_ref, v.routine_name, v.chunk_kind, + v.chunk_index, v.text_sha1, v.payload_sha1, v.text_preview, + e.embedding_json AS search_embedding_json, + c.owner_kind, c.owner_name, c.owner_guid, c.form_name, + c.extension_guid, c.extension_name, c.bsl_offset, + c.source_table, c.file_name + FROM metadata_code_embedding_cache e + JOIN metadata_code_vector_cache v + ON v.server_key=e.server_key AND v.database_name=e.database_name + AND v.chunk_id=e.chunk_id AND v.text_sha1=e.text_sha1 + JOIN metadata_code_index_cache c + ON c.server_key=v.server_key AND c.database_name=v.database_name + AND c.module_ref=v.module_ref AND c.text_sha1=v.text_sha1 + WHERE e.server_key=? AND e.database_name=? AND e.embedding_model=? + AND e.dimensions=? + ORDER BY e.updated_at DESC + LIMIT ? + """, + ( + cache_server_key(config), + cache_database_name(config), + embedding_model, + len(query_embedding), + int(scan_limit or 2000), + ), + ).fetchall() + saved_rows = conn.execute( + """ + SELECT source_table, file_name + FROM metadata_code_index_cache + WHERE server_key=? AND database_name=? AND source_table IN ('ConfigSave', 'ConfigCASSave') + GROUP BY source_table, file_name + """, + (cache_server_key(config), cache_database_name(config)), + ).fetchall() + tombstone_rows = conn.execute( + """ + SELECT source_table, file_name + FROM metadata_code_overlay_tombstone + WHERE server_key=? AND database_name=? + """, + (cache_server_key(config), cache_database_name(config)), + ).fetchall() + saved_files = {(str(row["source_table"]), str(row["file_name"])) for row in saved_rows} + tombstones = {(str(row["source_table"]), str(row["file_name"])) for row in tombstone_rows} + ranked: list[tuple[float, sqlite3.Row]] = [] + shadowed = 0 + for row in rows: + source_table = str(row["source_table"] or "") + saved_table = CODE_INDEX_SAVED_OVERLAYS.get(source_table) + if saved_table and ( + (saved_table, str(row["file_name"] or "")) in saved_files + or (saved_table, str(row["file_name"] or "")) in tombstones + ): + shadowed += 1 + continue + try: + embedding = numeric_vector(json.loads(row["search_embedding_json"] or "[]")) + except Exception: + embedding = None + vector_score = cosine_similarity(query_embedding, embedding or []) + lexical_score = ( + min(1.0, semantic_lexical_score(query, str(row["text_preview"] or ""), str(row["owner_name"] or "")) / 10.0) + if query + else 0.0 + ) + score = max(float(vector_score or 0.0), float(lexical_score)) + if score <= 0: + continue + ranked.append((score, row)) + ranked.sort(key=lambda pair: (-pair[0], str(pair[1]["chunk_id"] or ""))) + return ranked, shadowed + + candidates, shadowed_candidates = ranked_candidates() matches: list[dict[str, Any]] = [] - for score, row in candidates[: int(limit or 10)]: - freshness = { - "source": "code_vector_cache", - "verified_against_sql": False, - "payload_sha1": row["payload_sha1"], - "text_sha1": row["text_sha1"], - "status": "vector_candidate_unverified", - } - if verify: - verification = metadata_code_index_verify({"base_id": base_id, "module_ref": row["module_ref"], "timeout_seconds": payload.get("timeout_seconds", 30)}) - freshness = verification.get("freshness") or freshness - matches.append( + rejected_stale = 0 + rejected_unverified = 0 + refreshed_modules = 0 + verified_modules = 0 + candidate_window = max(int(limit or 10), int(limit or 10) * int(overfetch_factor or 5)) + for search_pass in range(2): + matches = [] + verification_by_module: dict[str, dict[str, Any]] = {} + refreshed_this_pass = False + for score, row in candidates[:candidate_window]: + freshness = { + "source": "code_vector_cache", + "verified_against_sql": False, + "payload_sha1": row["payload_sha1"], + "text_sha1": row["text_sha1"], + "status": "vector_candidate_unverified", + } + if verify: + module_ref = str(row["module_ref"] or "") + verification = verification_by_module.get(module_ref) + verification_new = verification is None + if verification is None: + verification = metadata_code_index_verify( + { + "base_id": base_id, + "module_ref": module_ref, + "timeout_seconds": payload.get("timeout_seconds", 30), + "refresh_stale": bool(strict), + } + ) + verification_by_module[module_ref] = verification + verified_modules += 1 + freshness = verification.get("freshness") or freshness + freshness_status = str(freshness.get("status") or "") + if freshness_status == "cache_refreshed_from_sql": + if verification_new: + refreshed_modules += 1 + refreshed_this_pass = True + continue + if strict and freshness_status != "cache_hit_verified": + if freshness_status == "cache_hit_stale": + rejected_stale += 1 + else: + rejected_unverified += 1 + continue + owner = code_index_owner_payload(config, str(row["module_ref"] or ""), dict(row)) + owner_extension = owner.get("extension") if isinstance(owner.get("extension"), dict) else {} + row_module_table, _row_file_name, _row_stream_index = parse_module_id(str(row["module_ref"] or "")) + if not owner.get("name") or (row_module_table == "ConfigCAS" and not owner_extension.get("name")): + metadata_module_owner_resolve_from_ref( + base_id, + config, + str(row["module_ref"] or ""), + timeout_seconds=int(payload.get("timeout_seconds") or 30), + ) + owner = code_index_owner_payload(config, str(row["module_ref"] or ""), dict(row)) + owner_extension = owner.get("extension") if isinstance(owner.get("extension"), dict) else {} + public_ref = object_selector_ref(owner.get("kind"), owner.get("name")) + matches.append( + { + "score": score, + "match_by": "vector_embedding", + "chunk": {"id": row["chunk_id"], "kind": row["chunk_kind"], "routine_name": row["routine_name"], "index": row["chunk_index"]}, + "text_preview": row["text_preview"], + "owner": owner, + "object_ref": public_ref or None, + "object_selector": { + **({"kind": owner.get("kind")} if owner.get("kind") else {}), + **({"name": owner.get("name")} if owner.get("name") else {}), + **({"guid": owner.get("guid")} if owner.get("guid") else {}), + **({"extension": owner_extension.get("name")} if owner_extension.get("name") else {}), + **({"extension_guid": owner_extension.get("guid")} if owner_extension.get("guid") else {}), + }, + "read_selector": { + "method": "modules.read", + "base_id": base_id, + "module_ref": row["module_ref"], + "preview": True, + "max_chars": int(payload.get("read_max_chars") or 4000), + **({"routine_name": row["routine_name"]} if row["routine_name"] else {}), + **({"bsl_offset": row["bsl_offset"]} if row["bsl_offset"] is not None else {}), + }, + "freshness": freshness, + } + ) + if len(matches) >= int(limit or 10): + break + if refreshed_this_pass and search_pass == 0: + candidates, pass_shadowed = ranked_candidates() + shadowed_candidates = max(shadowed_candidates, pass_shadowed) + continue + break + + index_state = code_index_state_status(config) + live_fallback: dict[str, Any] | None = None + if strict and query and index_state.get("status") == "dirty": + live_fallback = metadata_code_index_search( { - "score": score, - "match_by": "vector_embedding", - "chunk": {"id": row["chunk_id"], "kind": row["chunk_kind"], "routine_name": row["routine_name"], "index": row["chunk_index"]}, - "text_preview": row["text_preview"], - "owner": {"kind": row["owner_kind"], "name": row["owner_name"], "guid": row["owner_guid"], "form": row["form_name"]}, - "read_selector": { - "method": "modules.read", - "base_id": base_id, - "module_ref": row["module_ref"], - "preview": True, - "max_chars": int(payload.get("read_max_chars") or 4000), - **({"routine_name": row["routine_name"]} if row["routine_name"] else {}), - **({"bsl_offset": row["bsl_offset"]} if row["bsl_offset"] is not None else {}), - }, - "freshness": freshness, + "base_id": base_id, + "query": query, + "mode": "live", + "limit": int(limit or 10), + "scan_limit": int(scan_limit or 2000), + "timeout_seconds": payload.get("timeout_seconds", 30), } ) return { "schema": "onec_code_vector_search.v1", "method": method, - "status": "ok" if matches else "not_found", - **({"error": "not_found"} if not matches else {}), + "status": "ok" if matches or (live_fallback or {}).get("matches") else "not_found", + **({"error": "not_found"} if not matches and not (live_fallback or {}).get("matches") else {}), "base_id": base_id, - "source": {"kind": "code_vector_cache", "authoritative": False, "embedding_model": CODE_INDEX_VECTOR_MODEL}, - "query": {"query": query or None, "query_embedding": {"dimensions": len(query_embedding)}, "verify": bool(verify), "limit": int(limit or 10), "scan_limit": int(scan_limit or 2000)}, + "source": {"kind": "code_vector_cache", "authoritative": False, "embedding_model": embedding_model}, + "query": { + "query": query or None, + "query_embedding": {"dimensions": len(query_embedding)}, + "embedding_model": embedding_model, + "verify": bool(verify), + "strict": bool(strict), + "overfetch_factor": int(overfetch_factor or 5), + "limit": int(limit or 10), + "scan_limit": int(scan_limit or 2000), + }, "matches": matches, - "counts": {"matches": len(matches), "candidates": len(candidates)}, + "counts": { + "matches": len(matches), + "candidates": len(candidates), + "candidate_window": min(len(candidates), candidate_window), + "verified_modules": verified_modules, + "refreshed_modules": refreshed_modules, + "rejected_stale": rejected_stale, + "rejected_unverified": rejected_unverified, + "shadowed_candidates": shadowed_candidates, + "live_fallback_matches": len((live_fallback or {}).get("matches") or []), + }, + "freshness": { + "status": "live_verified_candidates" if index_state.get("status") == "clean" else "dirty_with_live_fallback", + "validation_required": False if strict else True, + "index_state": index_state, + "snapshot_token": index_state.get("snapshot_token"), + }, + **({"live_fallback": live_fallback} if live_fallback is not None else {}), + "diagnostics": [ + { + "message": ( + "Strict mode returns only candidates verified against live SQL. " + "When the local index is dirty, live_fallback covers newly added code until reindexing completes." + ) + } + ], } @@ -15471,7 +17814,7 @@ def public_visible_command(item: dict[str, Any], *, include_storage: bool = Fals def metadata_modules_cache_role() -> str: - return "object_modules_v3" + return "object_modules_v5" def metadata_module_owner_cache_role() -> str: @@ -15483,13 +17826,13 @@ def metadata_module_owner_cache_upsert( module_ref: str, owner: dict[str, Any], module: dict[str, Any] | None = None, -) -> None: +) -> int: normalized_module_ref = str(module_ref or "").strip() if not config or not normalized_module_ref: - return + return 0 owner_guid = str((owner or {}).get("guid") or "").lower() if not is_guid_text(owner_guid): - return + return 0 now = time.time() module_payload = { "module_name": (module or {}).get("name"), @@ -15498,6 +17841,7 @@ def metadata_module_owner_cache_upsert( "stream_index": (module or {}).get("stream_index"), "file_name": (module or {}).get("file_name"), "payload": (module or {}).get("payload"), + "extension": (module or {}).get("extension"), } payload_json = json.dumps(module_payload, ensure_ascii=False, sort_keys=True) if module_payload else None module_table, module_file_name, module_stream_index = parse_module_id(normalized_module_ref) @@ -15541,23 +17885,72 @@ def metadata_module_owner_cache_upsert( "last_seen_at": now, }, ) + module_ref_candidates = [normalized_module_ref] + if module_table in CODE_INDEX_SAVED_OVERLAYS: + module_ref_candidates.append( + f"{CODE_INDEX_SAVED_OVERLAYS[module_table]}:{normalized_module_ref.split(':', 1)[1]}" + ) + elif module_table in CODE_INDEX_ACTIVE_TABLES: + module_ref_candidates.append( + f"{CODE_INDEX_ACTIVE_TABLES[module_table]}:{normalized_module_ref.split(':', 1)[1]}" + ) + placeholders = ",".join(["?"] * len(module_ref_candidates)) + module_extension = module_payload.get("extension") if isinstance(module_payload.get("extension"), dict) else {} + enriched_code_rows = conn.execute( + f""" + UPDATE metadata_code_index_cache + SET owner_kind=COALESCE(owner_kind, ?), + owner_name=COALESCE(owner_name, ?), + owner_guid=COALESCE(owner_guid, ?), + extension_guid=COALESCE(extension_guid, ?), + extension_name=COALESCE(extension_name, ?) + WHERE server_key=? AND database_name=? + AND module_ref IN ({placeholders}) + AND ( + owner_kind IS NULL OR owner_name IS NULL OR owner_guid IS NULL + OR extension_guid IS NULL OR extension_name IS NULL + ) + """, + ( + owner.get("kind"), + owner.get("name"), + owner_guid, + module_extension.get("guid"), + module_extension.get("name"), + cache_server_key(config), + cache_database_name(config), + *module_ref_candidates, + ), + ).rowcount + return max(0, int(enriched_code_rows or 0)) def metadata_module_owner_cache_lookup(config: dict[str, str], module_ref: str) -> dict[str, Any] | None: normalized_module_ref = str(module_ref or "").strip() if not config or not normalized_module_ref: return None + candidate_refs = [normalized_module_ref] + module_table, _module_file_name, _module_stream_index = parse_module_id(normalized_module_ref) + if ":" in normalized_module_ref: + module_tail = normalized_module_ref.split(":", 1)[1] + if module_table in CODE_INDEX_SAVED_OVERLAYS: + candidate_refs.append(f"{CODE_INDEX_SAVED_OVERLAYS[module_table]}:{module_tail}") + elif module_table in CODE_INDEX_ACTIVE_TABLES: + candidate_refs.append(f"{CODE_INDEX_ACTIVE_TABLES[module_table]}:{module_tail}") row = None with cache_connection() as conn: - row = conn.execute( - """ - SELECT owner_guid, owner_kind, owner_name, owner_synonym, module_payload_json - FROM metadata_module_owner_cache - WHERE server_key = ? AND database_name = ? AND module_ref = ? - LIMIT 1 - """, - (cache_server_key(config), cache_database_name(config), normalized_module_ref), - ).fetchone() + for candidate_ref in candidate_refs: + row = conn.execute( + """ + SELECT owner_guid, owner_kind, owner_name, owner_synonym, module_payload_json + FROM metadata_module_owner_cache + WHERE server_key = ? AND database_name = ? AND module_ref = ? + LIMIT 1 + """, + (cache_server_key(config), cache_database_name(config), candidate_ref), + ).fetchone() + if row: + break if not row: return None payload = {} @@ -15578,6 +17971,479 @@ def metadata_module_owner_cache_lookup(config: dict[str, str], module_ref: str) } +def metadata_extension_module_owner_resolve_from_ref( + base_id: str, + config: dict[str, str], + module_ref: str, + *, + timeout_seconds: int = 30, +) -> dict[str, Any] | None: + module_table, file_name, stream_index = parse_module_id(str(module_ref or "").strip()) + if module_table != "ConfigCAS" or not file_name: + return None + related_entries, _diagnostics = manifest_related_entries_for_cas_key( + base_id, + file_name, + timeout_seconds=int(timeout_seconds or 30), + ) + if not related_entries: + return None + descriptor = next( + ( + entry + for entry in related_entries + if isinstance(entry, dict) and str(entry.get("suffix") or "") == "" and entry.get("cas_key") + ), + None, + ) + if not descriptor: + return None + descriptor_key = str(descriptor.get("cas_key") or "").strip().lower() + extension = descriptor.get("extension") if isinstance(descriptor.get("extension"), dict) else {} + row: dict[str, Any] | None = None + with cache_connection() as conn: + cached_row = conn.execute( + """ + SELECT * + FROM extension_route_cache + WHERE server_key=? AND database_name=? AND descriptor_cas_key=? + AND freshness_status!='stale' + LIMIT 1 + """, + (cache_server_key(config), cache_database_name(config), descriptor_key), + ).fetchone() + if cached_row: + row = dict(cached_row) + + owner_guid = str((row or {}).get("guid") or descriptor.get("object_base_id") or "").strip().lower() + owner_kind = str((row or {}).get("object_kind") or "").strip() + owner_name = str((row or {}).get("name") or "").strip() + owner_synonym = str((row or {}).get("synonym") or "").strip() or None + descriptor_payload_sha1: str | None = (row or {}).get("descriptor_payload_sha1") + if not owner_kind or not owner_name or not is_guid_text(owner_guid): + descriptor_data, _descriptor_config, descriptor_error = read_storage_file_bytes( + base_id, + "ConfigCAS", + descriptor_key, + timeout_seconds=int(timeout_seconds or 30), + ) + if descriptor_error or descriptor_data is None: + return None + try: + from parser.cas_payload import classify_payload + + classification = classify_payload(descriptor_data, include_text=False) + except Exception: + classification = {} + identity = config_identity_from_bytes(descriptor_data) or {} + owner_guid = str(identity.get("guid") or descriptor.get("object_base_id") or "").strip().lower() + owner_kind = str(extension_metadata_payload_kind(descriptor_data, identity, classification) or "").strip() + owner_name = str(identity.get("name") or "").strip() + synonyms = identity.get("synonyms") if isinstance(identity.get("synonyms"), dict) else {} + owner_synonym = next(iter(synonyms.values()), None) + descriptor_payload_sha1 = hashlib.sha1(descriptor_data).hexdigest() + if not owner_name or not is_guid_text(owner_guid): + return None + + extension_guid = str(extension.get("guid") or (row or {}).get("extension_guid") or "").strip().lower() + extension_name = str(extension.get("name") or (row or {}).get("extension_name") or "").strip() + public_extension = { + "guid": extension_guid if is_guid_text(extension_guid) else None, + "name": extension_name or None, + } + owner = { + "kind": owner_kind, + "name": owner_name, + "synonym": owner_synonym, + "guid": owner_guid, + } + # Once the descriptor has supplied a proven kind, resolve the exact BSL + # stream through the object's own manifest parts. This is deliberately + # not a suffix-to-role guess: only a module_id emitted by + # metadata.object.modules is retained as an ordinal/role route. + resolved_module: dict[str, Any] = {} + modules_result = metadata_object_modules( + { + "base_id": base_id, + "kind": owner_kind, + "name": owner_name, + "guid": owner_guid, + "extension": extension_name or extension_guid, + "extension_guid": extension_guid if is_guid_text(extension_guid) else None, + "table": "ConfigCAS", + "include_storage": True, + "timeout_seconds": int(timeout_seconds or 30), + } + ) if owner_kind else {} + if isinstance(modules_result, dict) and modules_result.get("status") == "ok": + for ordinal, module in enumerate(modules_result.get("modules") or [], start=1): + if not isinstance(module, dict) or not module_ref_matches(str(module.get("module_id") or ""), module_ref): + continue + resolved_module = { + "module_name": module.get("name"), + "module_type": module.get("kind"), + "module_ordinal": module.get("module_ordinal") or ordinal, + "stream_index": stream_index, + "file_name": file_name, + **({"form": module.get("form")} if isinstance(module.get("form"), dict) else {}), + } + break + route = { + "route_type": "extension_manifest_cas", + "table": "ConfigCAS", + "file_name": descriptor_key, + "manifest_entry": descriptor, + "manifest_entries": len(related_entries), + } + extension_route_cache_upsert( + config, + { + **owner, + "origin": {"source": "extension", "extension": public_extension}, + "route": route, + "manifest_entries": related_entries, + }, + descriptor_payload_sha1=descriptor_payload_sha1, + freshness_status="fresh", + ) + metadata_module_owner_cache_upsert( + config, + module_ref, + owner, + module={ + # This resolver is invoked only for a payload that has already + # produced a BSL search hit. The stream role is therefore proven + # as BSL, while a more specific object/form module role remains + # deliberately unknown until metadata evidence provides it. + "module_name": "Модуль БСЛ", + "module_type": "bsl_module", + "stream_index": stream_index, + "file_name": file_name, + "extension": public_extension, + **resolved_module, + }, + ) + return metadata_module_owner_cache_lookup(config, module_ref) + + +def metadata_module_owner_resolve_from_ref( + base_id: str, + config: dict[str, str], + module_ref: str, + *, + timeout_seconds: int = 30, +) -> dict[str, Any] | None: + module_table, file_name, _stream_index = parse_module_id(str(module_ref or "").strip()) + cached = metadata_module_owner_cache_lookup(config, module_ref) + if cached: + cached_module_payload = cached.get("module_payload") if isinstance(cached.get("module_payload"), dict) else {} + cached_extension = cached_module_payload.get("extension") if isinstance(cached_module_payload.get("extension"), dict) else {} + cached_owner = cached.get("owner") if isinstance(cached.get("owner"), dict) else {} + if module_table != "ConfigCAS" or ( + (cached_extension.get("guid") or cached_extension.get("name")) + and cached_owner.get("kind") + ): + return cached + if module_table == "ConfigCAS": + return metadata_extension_module_owner_resolve_from_ref( + base_id, + config, + module_ref, + timeout_seconds=int(timeout_seconds or 30), + ) + if module_table not in {"Config", "ConfigSave"} or not file_name: + return None + guid_match = re.match( + r"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?:\.|$)", + str(file_name), + ) + if not guid_match: + return None + owner_guid = guid_match.group(1).lower() + object_result = get_object( + None, + owner_guid, + base_id=base_id, + view="effective", + limit=2, + include_storage=False, + include_semantic=False, + timeout_seconds=int(timeout_seconds or 30), + table=module_table, + ) + if object_result.get("status") != "ok": + return None + object_card = object_result.get("object") if isinstance(object_result.get("object"), dict) else {} + if str(object_card.get("guid") or "").lower() != owner_guid: + return None + owner = { + "kind": object_card.get("kind"), + "name": object_card.get("name"), + "synonym": object_card.get("synonym"), + "guid": owner_guid, + } + if not owner.get("kind") or not owner.get("name"): + return None + metadata_module_owner_cache_upsert(config, module_ref, owner) + return metadata_module_owner_cache_lookup(config, module_ref) + + +def metadata_module_owner_backfill_kinds() -> list[str]: + return [ + kind + for kind in sorted(KIND_CAPABILITIES) + if "modules" in KIND_CAPABILITIES.get(kind, []) and "list" in KIND_CAPABILITIES.get(kind, []) + ] + + +def metadata_module_owner_cache_backfill(payload: dict[str, Any]) -> dict[str, Any]: + method = "metadata.module_owner_cache.backfill" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + config, config_error = sql_config_for_base(base_id) + if not config: + return public_error_result( + {"status": "source_missing", "diagnostics": config_error}, + include_storage=False, + method=method, + ) + limit, limit_error = parse_int_argument(payload, "limit", method=method, default=50, minimum=1, maximum=200) + if limit_error: + return limit_error + offset, offset_error = parse_int_argument(payload, "offset", method=method, default=0, minimum=0) + if offset_error: + return offset_error + kind_index, kind_index_error = parse_int_argument(payload, "kind_index", method=method, default=0, minimum=0) + if kind_index_error: + return kind_index_error + timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1, maximum=300) + if timeout_error: + return timeout_error + refresh_cache, refresh_cache_error = strict_bool_argument(payload, "refresh_cache", method=method, default=False) + if refresh_cache_error: + return refresh_cache_error + deep, deep_error = strict_bool_argument(payload, "deep", method=method, default=False) + if deep_error: + return deep_error + table = str(payload.get("table") or "Config").strip() + if table not in STORAGE_TABLES: + return invalid_argument(method, "table", "Unsupported storage table.", allowed_values=sorted(STORAGE_TABLES)) + + requested_kind = str(payload.get("kind") or "").strip() + all_kinds = metadata_module_owner_backfill_kinds() + if requested_kind: + current_kind = canonical_kind(requested_kind) + if current_kind not in all_kinds: + return invalid_argument( + method, + "kind", + "kind must be a listable metadata kind with BSL modules.", + allowed_values=all_kinds, + ) + current_kind_index = all_kinds.index(current_kind) + scoped_kinds = [current_kind] + else: + current_kind_index = int(kind_index or 0) + if current_kind_index >= len(all_kinds): + return { + "schema": "onec_module_owner_cache_backfill.v1", + "method": method, + "status": "complete", + "base_id": base_id, + "complete": True, + "next_cursor": None, + "counts": {"objects": 0, "modules": 0, "code_index_rows_enriched": 0, "errors": 0}, + } + current_kind = all_kinds[current_kind_index] + scoped_kinds = all_kinds + + with cache_connection() as conn: + before_entries = int( + conn.execute( + "SELECT COUNT(*) AS count FROM metadata_module_owner_cache WHERE server_key=? AND database_name=?", + (cache_server_key(config), cache_database_name(config)), + ).fetchone()["count"] + or 0 + ) + page = list_objects( + current_kind, + base_id=base_id, + limit=int(limit or 50), + offset=int(offset or 0), + include_storage=False, + refresh_cache=bool(refresh_cache), + table=table, + ) + if page.get("status") != "ok": + result = dict(page) + result.update({"schema": "onec_module_owner_cache_backfill.v1", "method": method}) + return result + + objects = [item for item in page.get("objects") or [] if isinstance(item, dict)] + modules_seen = 0 + enriched_code_rows = 0 + index_resolved_objects = 0 + metadata_resolved_objects = 0 + skipped_not_indexed_objects = 0 + errors: list[dict[str, Any]] = [] + processed_objects: list[dict[str, Any]] = [] + for object_card in objects: + object_guid = str(object_card.get("guid") or "").strip().lower() + indexed_module_refs: list[str] = [] + if is_guid_text(object_guid): + with cache_connection() as conn: + indexed_module_refs = [ + str(row["module_ref"]) + for row in conn.execute( + """ + SELECT module_ref + FROM metadata_code_index_cache + WHERE server_key=? AND database_name=? + AND source_table IN ('Config', 'ConfigSave') + AND (file_name=? OR file_name LIKE ?) + ORDER BY module_ref + """, + ( + cache_server_key(config), + cache_database_name(config), + object_guid, + f"{object_guid}.%", + ), + ).fetchall() + if row["module_ref"] + ] + if indexed_module_refs: + owner = { + "kind": object_card.get("kind") or current_kind, + "name": object_card.get("name"), + "synonym": object_card.get("synonym"), + "guid": object_guid, + } + for module_ref in indexed_module_refs: + metadata_module_owner_cache_upsert(config, module_ref, owner) + modules_seen += len(indexed_module_refs) + index_resolved_objects += 1 + processed_objects.append( + { + "ref": object_selector_ref(owner.get("kind"), owner.get("name")), + "modules": len(indexed_module_refs), + "resolved_by": "code_index_guid_prefix", + } + ) + continue + if not deep: + skipped_not_indexed_objects += 1 + processed_objects.append( + { + "ref": object_selector_ref(object_card.get("kind") or current_kind, object_card.get("name")), + "modules": 0, + "resolved_by": "not_indexed_skipped", + } + ) + continue + selector = { + "base_id": base_id, + "kind": object_card.get("kind") or current_kind, + "guid": object_card.get("guid"), + "table": table, + "include_storage": True, + "refresh_cache": bool(refresh_cache), + "timeout_seconds": int(timeout_seconds or 60), + } + modules_result = metadata_object_modules(selector) + if modules_result.get("status") != "ok": + errors.append( + { + "ref": object_selector_ref(object_card.get("kind") or current_kind, object_card.get("name")), + "status": modules_result.get("status"), + "error": modules_result.get("error"), + } + ) + continue + modules = [item for item in modules_result.get("modules") or [] if isinstance(item, dict)] + modules_seen += len(modules) + metadata_resolved_objects += 1 + owner = modules_result.get("object") if isinstance(modules_result.get("object"), dict) else object_card + processed_objects.append( + { + "ref": object_selector_ref(owner.get("kind") or current_kind, owner.get("name")), + "modules": len(modules), + "resolved_by": "metadata.object.modules", + } + ) + + # metadata.object.modules performs the owner-cache upsert. Count how many + # existing code-index rows gained an owner during this bounded page. + with cache_connection() as conn: + after_entries = int( + conn.execute( + "SELECT COUNT(*) AS count FROM metadata_module_owner_cache WHERE server_key=? AND database_name=?", + (cache_server_key(config), cache_database_name(config)), + ).fetchone()["count"] + or 0 + ) + enriched_code_rows = int( + conn.execute( + """ + SELECT COUNT(*) AS count + FROM metadata_code_index_cache + WHERE server_key=? AND database_name=? + AND owner_kind IS NOT NULL AND owner_name IS NOT NULL AND owner_guid IS NOT NULL + """, + (cache_server_key(config), cache_database_name(config)), + ).fetchone()["count"] + or 0 + ) + + page_counts = page.get("counts") if isinstance(page.get("counts"), dict) else {} + total = int(page_counts.get("total_visible") or page_counts.get("total") or 0) + next_offset = int(offset or 0) + len(objects) + same_kind_has_more = bool(objects) and (next_offset < total if total else len(objects) >= int(limit or 50)) + if same_kind_has_more: + next_cursor = {"kind_index": current_kind_index, "kind": current_kind, "offset": next_offset} + elif requested_kind or current_kind_index + 1 >= len(scoped_kinds): + next_cursor = None + else: + next_cursor = {"kind_index": current_kind_index + 1, "kind": scoped_kinds[current_kind_index + 1], "offset": 0} + complete = next_cursor is None + return { + "schema": "onec_module_owner_cache_backfill.v1", + "method": method, + "status": "complete" if complete else "ok", + "base_id": base_id, + "complete": complete, + "scope": { + "kind": current_kind, + "table": table, + "offset": int(offset or 0), + "limit": int(limit or 50), + "deep": bool(deep), + }, + "next_cursor": next_cursor, + "counts": { + "objects": len(objects), + "modules": modules_seen, + "cache_entries_before": before_entries, + "cache_entries_after": after_entries, + "cache_entries_added": max(0, after_entries - before_entries), + "code_index_rows_with_complete_owner": enriched_code_rows, + "objects_resolved_from_code_index": index_resolved_objects, + "objects_resolved_from_metadata": metadata_resolved_objects, + "objects_skipped_not_indexed": skipped_not_indexed_objects, + "errors": len(errors), + }, + "objects": processed_objects, + "errors": errors[:20], + "freshness": { + "source": "current_metadata", + "authoritative_code_source": "live_sql", + "writes_platform_sql": False, + }, + } + + def form_owner_module_refs(table: str, file_name: str) -> list[str]: if table not in STORAGE_TABLES or not file_name or Path(file_name).name != file_name: return [] @@ -16022,8 +18888,10 @@ def metadata_cache_invalidate(payload: dict[str, Any]) -> dict[str, Any]: dry_run, dry_run_error = strict_bool_argument(payload, "dry_run", method="metadata.cache.invalidate", default=False) if dry_run_error: return dry_run_error + preserve_code_index = bool(payload.get("_preserve_code_index")) base_ids = [base_id_or_error] deleted = 0 + full_invalidated_configs: list[dict[str, str]] = [] with cache_connection() as conn: for base_id in base_ids: config, _ = sql_config_for_base(base_id) @@ -16040,7 +18908,25 @@ def metadata_cache_invalidate(payload: dict[str, Any]) -> dict[str, Any]: "decoded_artifact_cache", "metadata_code_index_cache", "metadata_code_vector_cache", + "metadata_code_embedding_cache", + "metadata_code_index_state", + "metadata_code_index_outbox", + "metadata_code_overlay_tombstone", ) + if preserve_code_index: + cache_tables = tuple( + table_name + for table_name in cache_tables + if table_name + not in { + "metadata_code_index_cache", + "metadata_code_vector_cache", + "metadata_code_embedding_cache", + "metadata_code_index_state", + "metadata_code_index_outbox", + "metadata_code_overlay_tombstone", + } + ) if dry_run: for table_name in cache_tables: deleted += int( @@ -16056,18 +18942,67 @@ def metadata_cache_invalidate(payload: dict[str, Any]) -> dict[str, Any]: (cache_server_key(config), cache_database_name(config)), ) deleted += int(cursor.rowcount or 0) + if not preserve_code_index: + full_invalidated_configs.append(config) + for config in full_invalidated_configs: + for source_table in ("Config", "ConfigSave", "ConfigCAS", "ConfigCASSave"): + code_index_mark_dirty( + config, + reason="manual_cache_invalidate", + source_table=source_table, + event_kind="full_rebuild_required", + ) return {"schema": "onec_metadata_cache_invalidate.v1", "status": "ok", "dry_run": bool(dry_run), "counts": {"bases": len(base_ids), "deleted": deleted}} -def invalidate_adapter_caches_after_saved_state_change(base_id: str, *, reason: str) -> dict[str, Any]: +def invalidate_adapter_caches_after_saved_state_change( + base_id: str, + *, + reason: str, + source_table: str | None = None, + file_names: list[str] | None = None, + sync_code_index: bool = True, +) -> dict[str, Any]: """Invalidate persistent and process-local views after a committed saved-state mutation.""" normalized_base_id = str(base_id or "").strip() persistent: dict[str, Any] try: - persistent = metadata_cache_invalidate({"base_id": normalized_base_id, "dry_run": False}) + persistent = metadata_cache_invalidate( + { + "base_id": normalized_base_id, + "dry_run": False, + "_preserve_code_index": True, + } + ) except Exception as exc: persistent = {"status": "error", "diagnostics": {"message": str(exc)}} + config, _config_error = sql_config_for_base(normalized_base_id) + index_state = ( + code_index_mark_dirty( + config, + reason=reason, + source_table=source_table, + file_names=file_names, + ) + if config + else {"status": "source_missing"} + ) + code_index_sync: dict[str, Any] | None = None + if config and sync_code_index and source_table and file_names and len(file_names) <= 20: + try: + code_index_sync = metadata_code_index_sync_pending( + { + "base_id": normalized_base_id, + "limit": len(file_names), + "timeout_seconds": 30, + } + ) + sync_freshness = code_index_sync.get("freshness") if isinstance(code_index_sync.get("freshness"), dict) else {} + if isinstance(sync_freshness.get("index_state"), dict): + index_state = sync_freshness["index_state"] + except Exception as exc: + code_index_sync = {"status": "error", "diagnostics": {"message": str(exc)}} runtime_counts = { "base_root_metadata": 0, @@ -16127,6 +19062,8 @@ def invalidate_adapter_caches_after_saved_state_change(base_id: str, *, reason: "status": persistent_status, "deleted": int(((persistent.get("counts") or {}).get("deleted") or 0)), }, + "code_index": index_state, + **({"code_index_sync": code_index_sync} if code_index_sync is not None else {}), "runtime": runtime_counts, **( {"diagnostics": persistent.get("diagnostics")} @@ -16821,7 +19758,11 @@ def storage_files_list(payload: dict[str, Any]) -> dict[str, Any]: limit, limit_error = parse_int_argument(payload, "limit", method="storage.files.list", default=200, minimum=1, maximum=5000) if limit_error: return limit_error - prefix_error = validate_optional_non_empty_string_arguments(payload, "storage.files.list", ["prefix"]) + prefix_error = validate_optional_non_empty_string_arguments( + payload, + "storage.files.list", + ["prefix", "exact_file_name"], + ) if prefix_error: return prefix_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="storage.files.list", default=30, minimum=1) @@ -16831,6 +19772,7 @@ def storage_files_list(payload: dict[str, Any]) -> dict[str, Any]: if diagnostic_error: return diagnostic_error prefix = str(payload.get("prefix") or "") + exact_file_name = str(payload.get("exact_file_name") or "") like = f"{prefix}%" if prefix else "%" conn, config, error = connect_live_sql(base_id, "storage.files.list", timeout_seconds=int(timeout_seconds or 30)) if error: @@ -16840,19 +19782,34 @@ def storage_files_list(payload: dict[str, Any]) -> dict[str, Any]: try: with conn: with conn.cursor(as_dict=True) as cursor: - cursor.execute( - f""" - SELECT TOP ({limit}) - FileName, - COUNT(*) AS PartCount, - SUM(DATALENGTH(BinaryData)) AS Bytes - FROM dbo.[{table}] - WHERE FileName LIKE %s - GROUP BY FileName - ORDER BY FileName - """, - (like,), - ) + if exact_file_name: + cursor.execute( + f""" + SELECT TOP ({limit}) + FileName, + COUNT(*) AS PartCount, + SUM(DATALENGTH(BinaryData)) AS Bytes + FROM dbo.[{table}] + WHERE FileName = %s + GROUP BY FileName + ORDER BY FileName + """, + (exact_file_name,), + ) + else: + cursor.execute( + f""" + SELECT TOP ({limit}) + FileName, + COUNT(*) AS PartCount, + SUM(DATALENGTH(BinaryData)) AS Bytes + FROM dbo.[{table}] + WHERE FileName LIKE %s + GROUP BY FileName + ORDER BY FileName + """, + (like,), + ) rows = [{key: jsonable(value) for key, value in row.items()} for row in cursor.fetchall()] except Exception as exc: return { @@ -16864,12 +19821,35 @@ def storage_files_list(payload: dict[str, Any]) -> dict[str, Any]: "files": [], "counts": {"files": 0}, } + if payload.get("_internal") is not True and table in {"ConfigCAS", "ConfigCASSave"}: + active_guids = set(extension_map_by_guid(base_id)) + if table == "ConfigCASSave": + rows = [ + row for row in rows + if str(row.get("FileName") or "").split("__", 1)[0].strip().lower() in active_guids + ] + else: + manifests, _diagnostics = live_extension_manifests(base_id, timeout_seconds=int(timeout_seconds or 30)) + active_keys = { + str(manifest.get("root_cas_key") or "").lower() + for manifest in manifests if isinstance(manifest, dict) + } + active_keys.update( + str(entry.get("cas_key") or "").lower() + for manifest in manifests if isinstance(manifest, dict) + for entry in (manifest.get("entries") or []) if isinstance(entry, dict) + ) + rows = [row for row in rows if str(row.get("FileName") or "").lower() in active_keys] return { "schema": "onec_storage_files.v1", "status": "ok", "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": table}, - "query": {"prefix": prefix, "limit": limit}, + "query": { + "prefix": prefix, + "exact_file_name": exact_file_name or None, + "limit": limit, + }, "files": rows, "counts": {"files": len(rows)}, "duration_ms": int((time.time() - started) * 1000), @@ -17633,26 +20613,136 @@ def saved_state_prepare_file_names(payload: dict[str, Any], base_id: str, source if extension_name: if source_table != "ConfigCAS": return [], None, invalid_argument(method, "target_table", "Extension saved-state preparation must target ConfigCASSave.", allowed_values=["ConfigCASSave"]) + requested_form = str(payload.get("form") or payload.get("form_name") or "").strip() + if requested_form: + # A form name is not an extension object name. Resolve it through its + # public owner first, so a normal form prepare does not degrade into a + # whole-ConfigCAS scan or accidentally select a same-named form. + owner_ref = str(payload.get("ref") or "").strip() + owner_kind = str(payload.get("kind") or payload.get("object_type") or "").strip() + owner_name = str(payload.get("name") or payload.get("object_name") or "").strip() + if not (owner_ref or (owner_kind and owner_name)): + return [], {"mode": "extension_form_owner_required", "extension": extension_name, "form": requested_form}, { + "schema": "onec_saved_state_prepare.v1", + "method": method, + "status": "invalid_argument", + "base_id": base_id, + "error": "form_owner_required", + "diagnostics": { + "message": "For an extension form pass its public owner (ref or kind+name) together with form. A bare form name is not unique across an extension.", + "next_call": { + "method": "metadata.object.forms", + "params": {"base_id": base_id, "extension": extension_name, "ref": "", "form": requested_form}, + }, + }, + } + forms_result = metadata_object_forms( + { + "base_id": base_id, + "extension": extension_name, + **({"ref": owner_ref} if owner_ref else {"kind": owner_kind, "name": owner_name}), + "form": requested_form, + "include_storage": True, + "timeout_seconds": timeout_seconds, + } + ) + forms = forms_result.get("forms") if isinstance(forms_result.get("forms"), list) else [] + candidates = [] + for form in forms: + source = form.get("source") if isinstance(form, dict) and isinstance(form.get("source"), dict) else {} + candidate_name = str(source.get("file_name") or "").strip().lower() + if candidate_name and Path(candidate_name).name == candidate_name: + candidates.append((candidate_name, form)) + unique_candidates = {name: form for name, form in candidates} + if len(unique_candidates) == 1: + file_name, form = next(iter(unique_candidates.items())) + return [file_name], { + "mode": "extension_form_owner", + "kind": owner_kind or None, + "name": owner_name or None, + "ref": owner_ref or None, + "form": form.get("name") or requested_form, + "extension": extension_name, + }, None + return [], {"mode": "extension_form_owner", "extension": extension_name, "form": requested_form, "matches": len(unique_candidates)}, { + "schema": "onec_saved_state_prepare.v1", + "method": method, + "status": "not_found" if not unique_candidates else "ambiguous", + "base_id": base_id, + "error": "extension_form_route_not_resolved", + "diagnostics": { + "message": "The named extension form was not resolved to one active ConfigCAS payload through its owner. Review metadata.object.forms result; do not fall back to a global scan.", + "forms_status": forms_result.get("status"), + "matches": len(unique_candidates), + "next_call": { + "method": "metadata.object.forms", + "params": {"base_id": base_id, "extension": extension_name, **({"ref": owner_ref} if owner_ref else {"kind": owner_kind, "name": owner_name}), "form": requested_form, "include_storage": True}, + }, + }, + } config, _ = sql_config_for_base(base_id) - query = str(payload.get("query") or payload.get("name") or payload.get("object_name") or payload.get("guid") or payload.get("object_guid") or "").strip() - kind_filter = canonical_kind(str(payload.get("kind") or payload.get("object_type") or "")) + ref_kind, ref_name = parse_object_query(None, str(payload.get("ref") or "")) + query = str(payload.get("query") or payload.get("name") or payload.get("object_name") or ref_name or payload.get("guid") or payload.get("object_guid") or "").strip() + kind_filter = canonical_kind(str(payload.get("kind") or payload.get("object_type") or ref_kind or "")) guid_filter = str(payload.get("guid") or payload.get("object_guid") or "").strip().lower() rows = extension_route_cache_lookup( config, query=query, - kind_filter=kind_filter if kind_filter else None, + # Older cache rows may not yet have object_kind populated. Name + # resolution is still exact and is verified below, so do not turn + # a public ref into a false not_found solely through that cache + # enrichment gap. + kind_filter=None, guid_filter=guid_filter, extension_guid=None, limit=20, ) matches = [] - requested_name = str(payload.get("name") or payload.get("object_name") or "").strip() + requested_name = str(payload.get("name") or payload.get("object_name") or ref_name or "").strip() for row in rows: if extension_name and normalize(row.get("extension_name")) != normalize(extension_name) and str(row.get("extension_guid") or "").lower() != extension_name.lower(): continue if requested_name and normalize(row.get("name")) != normalize(requested_name): continue matches.append(extension_route_cache_row_to_match(base_id, row, include_storage=True, freshness={"status": "cache_hit_verified"})) + if len(matches) != 1: + # A prepare rollback invalidates route-cache entries for the saved + # target. Rebuild the exact active extension route internally once + # before treating the public selector as unresolved. The caller + # must not have to invoke cache maintenance or retry with a file + # key after a normal rollback. + extension_objects_find( + { + "base_id": base_id, + "extension": extension_name, + "query": query, + **({"kind": kind_filter} if kind_filter else {}), + **({"guid": guid_filter} if guid_filter else {}), + "state": "active", + # Manifest resolution repopulates this exact route on a + # miss. Do not force refresh_cache: that additionally + # performs a full ConfigCAS scan and makes a normal retry + # after rollback unnecessarily slow. + "refresh_cache": False, + "limit": 20, + "timeout_seconds": timeout_seconds, + } + ) + refreshed_rows = extension_route_cache_lookup( + config, + query=query, + kind_filter=None, + guid_filter=guid_filter, + extension_guid=None, + limit=20, + ) + matches = [] + for row in refreshed_rows: + if extension_name and normalize(row.get("extension_name")) != normalize(extension_name) and str(row.get("extension_guid") or "").lower() != extension_name.lower(): + continue + if requested_name and normalize(row.get("name")) != normalize(requested_name): + continue + matches.append(extension_route_cache_row_to_match(base_id, row, include_storage=True, freshness={"status": "refreshed_for_prepare"})) if len(matches) != 1: return [], {"mode": "extension_route_cache", "matches": len(matches), "extension": extension_name}, { "schema": "onec_saved_state_prepare.v1", @@ -17677,12 +20767,134 @@ def saved_state_prepare_file_names(payload: dict[str, Any], base_id: str, source cas_key = str(entry.get("cas_key") or "").strip().lower() if cas_key and Path(cas_key).name == cas_key: file_names.append(cas_key) + # ConfigCASSave's hash-keyed overlay is rooted at the extension + # manifest. Copying only an object leaf can pass SQL readback but + # leaves no saved root for Configurator to select. The root key is + # live manifest evidence, not a name reconstructed by the adapter. + root_cas_key = str(entry.get("root_cas_key") or "").strip().lower() + if root_cas_key and Path(root_cas_key).name == root_cas_key: + file_names.append(root_cas_key) + # The active extension root is already a ConfigInfo-style map: it + # carries logical object names and the SHA-1 of every referenced CAS + # payload. For a normal object module, the proven first-overlay route + # is its three logical files, not a same-name copy of hash keys: + # + # root manifest -> E__configinfo + # O descriptor -> E__O + # O module .S -> E__O.S + # + # The mapping is accepted only when the active manifest supplies one + # root, one descriptor, and the one *selected* module leaf from the + # decoded manifest, plus a concrete extension GUID. Module ordinal is + # not always `.0`: for example, the evidenced manager module route is + # `.2`. It must be taken from the resolved route, never guessed from + # the object kind or routine name. `metadata_saved_state_prepare` + # subsequently verifies all source rows before the atomic copy. Do not + # apply this to form payloads or an unverified suffix family. + extension_card = (match.get("origin") or {}).get("extension") if isinstance(match.get("origin"), dict) else {} + extension_guid = str((extension_card or {}).get("guid") or "").strip().lower() + object_guid = str(match.get("guid") or "").strip().lower() + root_keys = sorted( + { + str(entry.get("root_cas_key") or "").strip().lower() + for entry in (match.get("manifest_entries") or []) + if isinstance(entry, dict) + and str(entry.get("root_cas_key") or "").strip() + and Path(str(entry.get("root_cas_key") or "").strip()).name == str(entry.get("root_cas_key") or "").strip() + } + ) + descriptor_keys = sorted( + { + str(entry.get("cas_key") or "").strip().lower() + for entry in (match.get("manifest_entries") or []) + if isinstance(entry, dict) + and str(entry.get("suffix") or "") == "" + and str(entry.get("cas_key") or "").strip() + } + ) + # `route.file_name` is the object route's manifest root in some cache + # records, whereas a code-write resolver already has the exact BSL + # source stream. The latter is an internal, adapter-issued value and + # takes precedence solely to select the matching manifest leaf. + selected_source_ref = str(payload.get("_resolved_source_module_ref") or "").strip() + selected_source_table, selected_source_file_name, _selected_source_stream = parse_module_id(selected_source_ref) + selected_module_key = ( + selected_source_file_name.strip().lower() + if selected_source_table == "ConfigCAS" and selected_source_file_name + else route_file + ) + numeric_module_entries = [ + entry + for entry in (match.get("manifest_entries") or []) + if isinstance(entry, dict) + and re.fullmatch(r"\.\d+", str(entry.get("suffix") or "")) + ] + selected_module_entries = [ + entry + for entry in numeric_module_entries + if str(entry.get("cas_key") or "").strip().lower() == selected_module_key + ] + # A public prepare has no routine/stream reference. It is still safe + # for a single-leaf object route: there is no alternative module + # ordinal to choose. More than one leaf remains unresolved rather than + # selecting an ordinal by convention. + if not selected_source_ref and not selected_module_entries and len(numeric_module_entries) == 1: + selected_module_entries = numeric_module_entries + saved_state_name_map: dict[str, str] = {} + if is_guid_text(extension_guid) and is_guid_text(object_guid) and len(root_keys) == len(descriptor_keys) == len(selected_module_entries) == 1: + selected_module = selected_module_entries[0] + module_key = str(selected_module.get("cas_key") or "").strip().lower() + module_suffix = str(selected_module.get("suffix") or "") + saved_state_name_map = { + root_keys[0]: f"{extension_guid}__configinfo", + descriptor_keys[0]: f"{extension_guid}__{object_guid}", + module_key: f"{extension_guid}__{object_guid}{module_suffix}", + } + file_names = list(saved_state_name_map) + else: + # Do not silently preserve hash keys when the descriptor family is + # incomplete: it yields a SQL-only overlay invisible to + # Configurator. + file_names = [] + # Form payloads are a separate protocol family. They are included + # only for an explicitly selected form, never for an object-module + # write, so that the three-file object overlay remains exact. + include_related_parts = bool(payload.get("form") or payload.get("form_name")) and payload.get("include_related_parts") is not False + related_forms: list[dict[str, Any]] = [] + if include_related_parts and canonical_kind(str(match.get("kind") or "")) == "Report": + forms_result = metadata_object_forms( + { + "base_id": base_id, + "extension": extension_name, + "kind": match.get("kind"), + "name": match.get("name"), + "include_storage": True, + "timeout_seconds": timeout_seconds, + } + ) + for form in forms_result.get("forms") or []: + if not isinstance(form, dict): + continue + source = form.get("source") if isinstance(form.get("source"), dict) else {} + form_file = str(source.get("file_name") or "").strip().lower() + if form_file and Path(form_file).name == form_file: + file_names.append(form_file) + related_forms.append({ + "name": form.get("name"), + "guid": form.get("guid"), + "file_name": form_file, + }) return sorted(set(file_names)), { "mode": "extension_route_cache", "kind": match.get("kind"), "guid": match.get("guid"), "name": match.get("name"), "extension": (match.get("origin") or {}).get("extension") if isinstance(match.get("origin"), dict) else None, + "related_forms": related_forms, + "include_related_parts": include_related_parts, + "saved_state_root_keys": root_keys, + "saved_state_name_map": saved_state_name_map, + "saved_state_protocol": "canonical_configinfo_pair" if saved_state_name_map else "unresolved_hash_layout", }, None guid, kind, object_card, error = resolve_object_guid( @@ -17716,6 +20928,7 @@ def apply_saved_state_prepare_copy( *, expected_source_rows: int, prepared_rows: list[dict[str, Any]], + target_name_by_source: dict[str, str] | None = None, timeout_seconds: int, ) -> dict[str, Any]: method = "metadata.saved_state.prepare" @@ -17724,12 +20937,16 @@ def apply_saved_state_prepare_copy( return error started = time.time() names = sorted(set(file_names)) + target_name_by_source = target_name_by_source or {} + target_names = [str(target_name_by_source.get(name) or name) for name in names] + if len(set(target_names)) != len(target_names) or any(Path(name).name != name for name in target_names): + return {"schema": "onec_saved_state_prepare.v1", "status": "invalid_target_name_map", "applied": False, "base_id": base_id} try: cursor = conn.cursor(as_dict=True) placeholders = ",".join(["%s"] * len(names)) cursor.execute( f"SELECT FileName, PartNo FROM dbo.[{target_table}] WITH (UPDLOCK, HOLDLOCK) WHERE FileName IN ({placeholders})", - tuple(names), + tuple(target_names), ) collisions = [{key: jsonable(value) for key, value in row.items()} for row in cursor.fetchall()] if collisions: @@ -17745,17 +20962,20 @@ def apply_saved_state_prepare_copy( "diagnostics": {"message": "Target saved-state table already contains planned FileName values. Do not overwrite human or pending Configurator changes."}, } column_list = ", ".join(f"[{column}]" for column in SAVED_STATE_COPY_COLUMNS) - source_columns = ", ".join(f"s.[{column}]" for column in SAVED_STATE_COPY_COLUMNS) - cursor.execute( - f""" - INSERT INTO dbo.[{target_table}] ({column_list}) - SELECT {source_columns} - FROM dbo.[{source_table}] AS s - WHERE s.FileName IN ({placeholders}) - """, - tuple(names), - ) - inserted = int(cursor.rowcount or 0) + source_columns = ", ".join(f"s.[{column}]" for column in SAVED_STATE_COPY_COLUMNS if column != "FileName") + inserted = 0 + for source_name in names: + target_name = str(target_name_by_source.get(source_name) or source_name) + cursor.execute( + f""" + INSERT INTO dbo.[{target_table}] ({column_list}) + SELECT %s, {source_columns} + FROM dbo.[{source_table}] AS s + WHERE s.FileName=%s + """, + (target_name, source_name), + ) + inserted += int(cursor.rowcount or 0) if inserted != expected_source_rows: conn.rollback() return { @@ -17802,11 +21022,13 @@ def apply_saved_state_prepare_copy( "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": source_table}, "target": {"table": target_table}, - "counts": {"inserted_rows": inserted, "file_names": len(names)}, + "counts": {"inserted_rows": inserted, "file_names": len(target_names)}, "prepare_receipt": receipt, "cache_invalidation": invalidate_adapter_caches_after_saved_state_change( base_id, reason="saved_state_prepare", + source_table=target_table, + file_names=target_names, ), "duration_ms": int((time.time() - started) * 1000), } @@ -17849,6 +21071,7 @@ def write_saved_state_prepare_receipt( "target": {"table": target_table}, "rows": normalized_rows, "status": "prepared", + **({"job_id": str(getattr(ADAPTER_JOB_CONTEXT, "job_id", "") or "")} if getattr(ADAPTER_JOB_CONTEXT, "job_id", None) else {}), } path.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return { @@ -18021,6 +21244,8 @@ def rollback_saved_state_prepare_receipt( "cache_invalidation": invalidate_adapter_caches_after_saved_state_change( base_id, reason="saved_state_prepare_rollback", + source_table=target_table, + file_names=file_names, ), "duration_ms": int((time.time() - started) * 1000), } @@ -18061,6 +21286,7 @@ def public_saved_state_prepare_call_payload(payload: dict[str, Any]) -> dict[str ref = str(payload.get("ref") or "").strip() kind = str(payload.get("kind") or payload.get("object_type") or "").strip() name = str(payload.get("name") or payload.get("object_name") or "").strip() + form = str(payload.get("form") or payload.get("form_name") or "").strip() if ref and not is_guid_text(ref): ref_kind, ref_name = parse_object_query(None, ref) kind = kind or str(ref_kind or "") @@ -18075,7 +21301,9 @@ def public_saved_state_prepare_call_payload(payload: dict[str, Any]) -> dict[str **({"kind": canonical_kind(kind)} if kind else {}), **({"name": name} if name else {}), **({"extension": payload.get("extension")} if isinstance(payload.get("extension"), str) and payload.get("extension") else {}), + **({"form": form} if form else {}), **({"query": payload.get("query")} if isinstance(payload.get("query"), str) and payload.get("query") else {}), + **({"merge_missing": True} if payload.get("merge_missing") is True else {}), "mode": "plan", } if not any(result.get(key) for key in ("ref", "name", "query")): @@ -18083,6 +21311,27 @@ def public_saved_state_prepare_call_payload(payload: dict[str, Any]) -> dict[str return result +def cleanup_job_prepare_receipts(job_id: str, base_id: str, *, timeout_seconds: int) -> list[dict[str, Any]]: + """Best-effort compensation after an isolated write worker is terminated.""" + cleaned: list[dict[str, Any]] = [] + root = saved_state_prepare_receipt_dir() + for path in sorted(root.glob("*.json")) if root.is_dir() else []: + try: + evidence = json.loads(path.read_text(encoding="utf-8-sig")) + except Exception: + continue + if ( + str(evidence.get("job_id") or "") != str(job_id) + or str(evidence.get("base_id") or "") != str(base_id) + or str(evidence.get("status") or "") != "prepared" + ): + continue + receipt_id = str(evidence.get("receipt_id") or "") + if re.fullmatch(r"[0-9a-f]{32}", receipt_id): + cleaned.append(rollback_saved_state_prepare_receipt(base_id, receipt_id, timeout_seconds=timeout_seconds)) + return cleaned + + def public_saved_state_prepare_embedded_result(result: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]: target_table = str(payload.get("target_table") or payload.get("table") or "").strip() source_table = str(payload.get("source_table") or "").strip() @@ -18151,6 +21400,9 @@ def public_saved_state_prepare_result( "note": "Storage coordinates, files, hashes, and row details are hidden unless include_storage=true.", }, } + raw_diagnostics = result.get("diagnostics") if isinstance(result.get("diagnostics"), dict) else {} + if isinstance(raw_diagnostics.get("next_call"), dict): + public["next_call"] = raw_diagnostics["next_call"] if result.get("error"): public["error"] = result.get("error") verification = result.get("verification") if isinstance(result.get("verification"), dict) else None @@ -18162,6 +21414,38 @@ def public_saved_state_prepare_result( "actual_records": verification.get("actual_rows"), "mismatched_records": len(mismatched), } + # The receipt is an opaque adapter capability, not a storage coordinate. + # It must remain available in the public response: otherwise an agent can + # create a saved-state copy but cannot invoke the deliberately separate, + # guarded cleanup endpoint. Do not disclose the receipt path or its row + # hashes unless include_storage was explicitly requested. + prepare_receipt = apply_result.get("prepare_receipt") if isinstance(apply_result.get("prepare_receipt"), dict) else {} + prepare_receipt_id = str(prepare_receipt.get("receipt_id") or "").strip().lower() + if re.fullmatch(r"[0-9a-f]{32}", prepare_receipt_id): + public["prepare_receipt_id"] = prepare_receipt_id + if bool(result.get("applied")) and int(apply_counts.get("inserted_rows") or 0) > 0: + is_extension_saved_state = target_table == "ConfigCASSave" + public["configurator_refresh"] = { + "required": True, + "scope": "extension" if is_extension_saved_state else "configuration", + "action": "close_reopen_extension" if is_extension_saved_state else "close_reopen_configuration", + "reason": ( + "The adapter created a new extension saved-state overlay; an already open Configurator can still use the active extension until it is reopened." + if is_extension_saved_state + else "The adapter created a new base-configuration saved-state overlay outside Configurator memory." + ), + } + stages = result.get("stages") if isinstance(result.get("stages"), list) else [] + if stages: + public["stages"] = [ + { + "name": stage.get("name"), + "status": stage.get("status"), + "elapsed_ms": stage.get("elapsed_ms"), + } + for stage in stages + if isinstance(stage, dict) + ] return public @@ -18189,6 +21473,17 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: if include_storage_error: return include_storage_error include_storage = bool(include_storage) + # Extension saved-state is a working overlay: a repeated prepare must add + # only absent confirmed parts and must never overwrite existing work. + merge_missing, merge_missing_error = strict_bool_argument( + payload, + "merge_missing", + method=method, + default=bool(str(payload.get("extension") or "").strip()), + ) + if merge_missing_error: + return merge_missing_error + merge_missing = bool(merge_missing) layer = str(payload.get("layer") or "").strip() if layer and layer not in SAVED_STATE_TABLE_BY_LAYER: return invalid_argument(method, "layer", "layer must be base_saved_state or extension_saved_state.", allowed_values=sorted(SAVED_STATE_TABLE_BY_LAYER)) @@ -18213,8 +21508,20 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: return invalid_argument(method, "source_table", f"source_table must be {expected_source_table} for {target_table}.", allowed_values=[expected_source_table]) source_table = expected_source_table + stages: list[dict[str, Any]] = [] + def complete_stage(name: str, started_at: float, status: str, **details: Any) -> None: + stages.append({ + "name": name, + "status": status, + "elapsed_ms": round((time.monotonic() - started_at) * 1000, 1), + **details, + }) + + stage_started = time.monotonic() file_names, object_card, file_error = saved_state_prepare_file_names(payload, base_id, source_table, timeout_seconds) + complete_stage("resolve_source", stage_started, "error" if file_error else "ok", file_names=len(file_names)) if file_error: + file_error.setdefault("stages", stages) return file_error if include_storage else public_saved_state_prepare_result( file_error, base_id=base_id, @@ -18232,6 +21539,7 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: "target": {"table": target_table}, "object": object_card, "counts": {"file_names": 0}, + "stages": stages, } return no_rows_result if include_storage else public_saved_state_prepare_result( no_rows_result, @@ -18239,16 +21547,71 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: target_table=target_table, object_card=object_card, ) + canonical_name_map = ( + object_card.get("saved_state_name_map") + if target_table == "ConfigCASSave" and isinstance(object_card, dict) and isinstance(object_card.get("saved_state_name_map"), dict) + else {} + ) + saved_state_protocol = ( + str(object_card.get("saved_state_protocol") or "").strip() + if isinstance(object_card, dict) + else "" + ) + # A same-key ConfigCAS -> ConfigCASSave copy is useful forensic evidence, + # but it is not a proven Configurator working-copy protocol. In + # `фс_Отчеты` it produced an exact SQL readback while Configurator continued + # to select ConfigCAS. Do not report that result as a writable saved state + # or let code.write mutate it. A write becomes available only after a + # layout-specific reverse codec has been evidenced (for example the + # canonical __configinfo paired-container codec) and supplies an explicit + # target-name map. + if ( + target_table == "ConfigCASSave" + and saved_state_protocol == "source_key_copy" + and not canonical_name_map + ): + blocked = { + "schema": "onec_saved_state_prepare.v1", + "method": method, + "status": "extension_saved_state_prepare_protocol_unproven", + "applied": False, + "base_id": base_id, + "source": {"kind": "live_sql", "table": source_table}, + "target": {"table": target_table}, + "object": object_card, + "saved_state_protocol": saved_state_protocol, + "diagnostics": { + "message": "The hash-keyed extension layout has no proven reverse working-copy codec. The adapter refuses to create a ConfigCASSave copy that SQL can read but Configurator does not select.", + "next_action": "adapter_protocol_development_required", + }, + "stages": stages, + } + return blocked if include_storage else public_saved_state_prepare_result( + blocked, + base_id=base_id, + target_table=target_table, + object_card=object_card, + ) + target_name_by_source = {str(name): str(canonical_name_map.get(name) or name) for name in file_names} + target_file_name_list = [target_name_by_source[name] for name in file_names] + if len(set(target_file_name_list)) != len(target_file_name_list): + return invalid_argument(method, "extension", "Resolved extension save protocol maps active parts to duplicate Configurator descriptor names.") + stage_started = time.monotonic() source_rows, config, source_error = saved_state_copy_row_details(base_id, source_table, file_names, timeout_seconds=timeout_seconds) + complete_stage("inspect_active_source", stage_started, "error" if source_error else "ok", file_names=len(file_names), rows=len(source_rows or [])) if source_error: + source_error.setdefault("stages", stages) return source_error if include_storage else public_saved_state_prepare_result( source_error, base_id=base_id, target_table=target_table, object_card=object_card, ) - target_rows, _target_config, target_error = saved_state_copy_row_details(base_id, target_table, file_names, timeout_seconds=timeout_seconds) + stage_started = time.monotonic() + target_rows, _target_config, target_error = saved_state_copy_row_details(base_id, target_table, target_file_name_list, timeout_seconds=timeout_seconds) + complete_stage("inspect_saved_target", stage_started, "error" if target_error else "ok", file_names=len(target_file_name_list), rows=len(target_rows or [])) if target_error: + target_error.setdefault("stages", stages) return target_error if include_storage else public_saved_state_prepare_result( target_error, base_id=base_id, @@ -18258,28 +21621,141 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: source_rows = source_rows or [] target_rows = target_rows or [] public_source_rows = [saved_state_row_public(row) for row in source_rows] - if len(public_source_rows) == len(file_names): - for index, row in enumerate(public_source_rows): - if not row.get("file_name"): - row["file_name"] = file_names[index] + for row in public_source_rows: + source_name = str(row.get("file_name") or "") + if source_name: + row["source_file_name"] = source_name + row["file_name"] = target_name_by_source.get(source_name, source_name) public_target_rows = [saved_state_row_public(row) for row in target_rows] - status = "plan_ready" if source_rows and not target_rows else ("blocked_target_collision" if target_rows else "blocked_no_active_source_rows") + target_file_names = {str(row.get("FileName") or row.get("file_name") or "") for row in target_rows} + configinfo_targets = { + target_name for target_name in target_file_name_list + if target_name.lower().endswith("__configinfo") + } + missing_configinfo = sorted(name for name in configinfo_targets if name not in target_file_names) + # A canonical mapping has already proved that its active root is the + # ConfigInfo-compatible source for this exact extension/object route. It + # must be inserted with the descriptor and module on the first overlay; + # otherwise Configurator has no logical file map to select. An existing + # saved ConfigInfo remains untouched during merge. + if missing_configinfo and not canonical_name_map: + blocked = { + "schema": "onec_saved_state_prepare.v1", + "method": method, + "status": "extension_saved_state_prepare_protocol_unproven", + "applied": False, + "base_id": base_id, + "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": source_table}, + "target": {"table": target_table}, + "object": object_card, + "missing_configinfo": missing_configinfo, + "diagnostics": {"message": "The extension saved-state route lacks a proven ConfigInfo-compatible source. The adapter refuses to invent a ConfigCASSave preparation transform."}, + "stages": stages, + } + return blocked if include_storage else public_saved_state_prepare_result(blocked, base_id=base_id, target_table=target_table, object_card=object_card) + copy_file_names = [ + name for name in file_names + if target_name_by_source[name] not in target_file_names + ] + copy_source_rows = [row for row in source_rows if str(row.get("FileName") or row.get("file_name") or "") in set(copy_file_names)] + if source_rows and not target_rows: + status = "plan_ready" + elif target_rows and merge_missing and copy_source_rows: + status = "plan_ready_merge" + elif target_rows and merge_missing: + status = "already_prepared" + else: + status = "blocked_target_collision" if target_rows else "blocked_no_active_source_rows" result: dict[str, Any] = { "schema": "onec_saved_state_prepare.v1", "status": status, "applied": False, - "ready_to_copy": status == "plan_ready", + "ready_to_copy": status in {"plan_ready", "plan_ready_merge"}, "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": source_table}, "target": {"table": target_table}, "object": object_card, - "file_names": file_names, + "file_names": target_file_name_list, + "source_file_names": file_names, + "copy_file_names": [target_name_by_source[name] for name in copy_file_names], + "copy_source_file_names": copy_file_names, + "saved_state_protocol": (object_card or {}).get("saved_state_protocol") if isinstance(object_card, dict) else None, "source_rows": public_source_rows, "target_collisions": public_target_rows, - "counts": {"file_names": len(file_names), "source_rows": len(source_rows), "target_rows": len(target_rows)}, + "counts": {"file_names": len(target_file_name_list), "copy_file_names": len(copy_file_names), "source_rows": len(source_rows), "copy_source_rows": len(copy_source_rows), "target_rows": len(target_rows)}, "write_mode": {"sql_write_performed": False, "requires_allow_flag": True}, + "stages": stages, } - if mode == "plan" or status != "plan_ready": + + def append_prepared_form_parts(*, verified: bool) -> None: + """Expose the exact embedded form-module handle after a form copy. + + A managed-form BSL module is a stream inside the form payload, rather + than a second ConfigCAS/ConfigCASSave row. Resolve it from the copied + form record itself instead of making the caller rediscover storage. + """ + prepared_form = str(payload.get("form") or payload.get("form_name") or "").strip() + prepared_extension = str(payload.get("extension") or "").strip() + if target_table != "ConfigCASSave" or not prepared_extension or not prepared_form: + return + prepared_kind = str(payload.get("kind") or payload.get("object_type") or "").strip() + prepared_name = str(payload.get("name") or payload.get("object_name") or "").strip() + prepared_ref = str(payload.get("ref") or "").strip() + if prepared_ref and not (prepared_kind and prepared_name): + parsed_kind, parsed_name = parse_object_query(None, prepared_ref) + prepared_kind = prepared_kind or str(parsed_kind or "") + prepared_name = prepared_name or str(parsed_name or "") + module_search = metadata_saved_state_modules_search( + { + "base_id": base_id, + "layer": "extension_saved_state", + "extension": prepared_extension, + "object_type": prepared_kind, + "object_name": prepared_name, + "form": prepared_form, + "limit": 10, + "scan_limit": 10, + "include_storage": True, + "timeout_seconds": timeout_seconds, + } + ) + streams = [ + stream + for module in module_search.get("modules") or [] + if isinstance(module, dict) + for stream in module.get("streams") or [] + if isinstance(stream, dict) and stream.get("module_ref") + ] + module_refs = [str(stream["module_ref"]) for stream in streams] + result["prepared_modules"] = { + "status": "resolved" if len(module_refs) == 1 else ("not_found" if not module_refs else "ambiguous"), + "module_refs": module_refs, + "search_counts": module_search.get("counts"), + "readback_verified": verified, + } + form_file_names = [str(name) for name in file_names if name] + parts: list[dict[str, Any]] = [ + { + "part_kind": "form", + "form": prepared_form, + **({"saved_state_ref": f"ConfigCASSave:{form_file_names[0]}"} if len(form_file_names) == 1 else {}), + } + ] + if len(streams) == 1: + stream = streams[0] + parts.append({ + "part_kind": "form_module", + "form": prepared_form, + "module_ref": stream["module_ref"], + "text_sha1": stream.get("text_sha1"), + }) + result["prepared_parts"] = parts + if status == "already_prepared": + # Idempotent prepare is also the normal continuation after an earlier + # agent turn. Return the same verified module handle instead of + # forcing the caller to issue a separate storage-oriented search. + append_prepared_form_parts(verified=True) + if mode == "plan" or status == "already_prepared" or status not in {"plan_ready", "plan_ready_merge"}: return result if include_storage else public_saved_state_prepare_result( result, base_id=base_id, @@ -18291,19 +21767,27 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: return allow_error if not allow_prepare: return invalid_argument(method, "allow_sql_saved_state_prepare", "Saved-state preparation writes SQL inserts; pass allow_sql_saved_state_prepare=true after reviewing the plan.") + stage_started = time.monotonic() apply_result = apply_saved_state_prepare_copy( base_id, source_table, target_table, - file_names, - expected_source_rows=len(source_rows), - prepared_rows=public_source_rows, + copy_file_names, + expected_source_rows=len(copy_source_rows), + prepared_rows=[ + {**saved_state_row_public(row), "file_name": target_name_by_source.get(str(row.get("FileName") or row.get("file_name") or ""), str(row.get("FileName") or row.get("file_name") or ""))} + for row in copy_source_rows + ], + target_name_by_source={name: target_name_by_source[name] for name in copy_file_names}, timeout_seconds=timeout_seconds, ) + complete_stage("copy_atomic", stage_started, "ok" if apply_result.get("applied") else "error", inserted_rows=int((apply_result.get("counts") or {}).get("inserted_rows") or 0)) result["apply_result"] = apply_result result["applied"] = bool(apply_result.get("applied")) result["write_mode"]["sql_write_performed"] = bool(apply_result.get("applied")) result["status"] = apply_result.get("status") or "error" + if result["applied"]: + append_prepared_form_parts(verified=False) if mode == "apply" or not result["applied"]: return result if include_storage else public_saved_state_prepare_result( result, @@ -18311,7 +21795,9 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: 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) + stage_started = time.monotonic() + verify_rows, _verify_config, verify_error = saved_state_copy_row_details(base_id, target_table, target_file_name_list, timeout_seconds=timeout_seconds) + complete_stage("verify_copy", stage_started, "error" if verify_error else "ok", rows=len(verify_rows or [])) if verify_error: result["status"] = "verify_error" result["verify_error"] = verify_error @@ -18321,7 +21807,21 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: 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} + # A merge must prove two different invariants: rows inserted by this call + # must equal their active source, while pre-existing pending rows must stay + # exactly as they were. In particular an extension __configinfo can be a + # newer Configurator map than ConfigCAS, so comparing it with active bytes + # rejects a correct overlay and leaves a successful insert marked failed. + expected = { + (target_name_by_source.get(str(row.get("FileName") or ""), str(row.get("FileName") or "")), row.get("PartNo")): + {**saved_state_row_public(row), "file_name": target_name_by_source.get(str(row.get("FileName") or ""), str(row.get("FileName") or ""))} + for row in copy_source_rows + } + expected.update({ + (str(row.get("FileName") or row.get("file_name") or ""), row.get("PartNo") if "PartNo" in row else row.get("part_no")): + saved_state_row_public(row) + for row in target_rows + }) actual = {(row.get("FileName"), row.get("PartNo")): saved_state_row_public(row) for row in (verify_rows or [])} mismatched = [ {"expected": expected[key], "actual": actual.get(key)} @@ -18335,6 +21835,8 @@ 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" + if result["status"] == "verified": + append_prepared_form_parts(verified=True) return result if include_storage else public_saved_state_prepare_result( result, base_id=base_id, @@ -18343,6 +21845,86 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]: ) +def metadata_saved_state_ensure(payload: dict[str, Any]) -> dict[str, Any]: + """Idempotent public facade for creating a base or extension save layer. + + ``prepare`` is deliberately low-level enough to expose a copy plan. This + facade is the normal agent entry point: it always uses merge-missing + semantics, keeps the public selector intact, and makes it explicit that a + missing save is an adapter operation, not a Configurator prerequisite. + """ + method = "metadata.saved_state.ensure" + execution_mode = str(payload.get("execution_mode") or "").strip().casefold() + if execution_mode: + if execution_mode != "job": + return invalid_argument(method, "execution_mode", "execution_mode may only be job when provided.", allowed_values=["job"]) + return adapter_start_job({ + "method": method, + "payload": {key: value for key, value in payload.items() if key != "execution_mode"}, + "timeout_seconds": payload.get("timeout_seconds") or 60, + }) + + prepare_payload = dict(payload) + prepare_payload.pop("execution_mode", None) + prepare_payload["merge_missing"] = True + result = metadata_saved_state_prepare(prepare_payload) + status = str(result.get("status") or "error") + mode = str(payload.get("mode") or "plan").strip().casefold() + ready = status in {"already_prepared", "verified"} + next_action: str + if ready: + next_action = "write_saved_state" + elif status in {"plan_ready", "plan_ready_merge"}: + next_action = "apply_with_allow_sql_saved_state_prepare" + elif status == "blocked_target_collision": + next_action = "review_existing_saved_state" + else: + next_action = "resolve_diagnostics" + + return { + "schema": "onec_saved_state_ensure.v1", + "method": method, + "status": "ready" if ready else status, + "base_id": result.get("base_id") or payload.get("base_id"), + "mode": mode, + "saved_state": { + "ready": ready, + "created": bool(result.get("applied")), + "next_action": next_action, + "manual_configurator_required": False, + "manual_configurator_note": "A missing saved-state copy is created by the adapter; do not ask the user to create it in Configurator.", + }, + "prepare": result, + } + + +def metadata_saved_state_ensure_rollback(payload: dict[str, Any]) -> dict[str, Any]: + """Rollback only an adapter-created ensure receipt, never active metadata.""" + method = "metadata.saved_state.ensure.rollback" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + receipt_id = str(payload.get("prepare_receipt_id") or "").strip().lower() + if not re.fullmatch(r"[0-9a-f]{32}", receipt_id): + return invalid_argument(method, "prepare_receipt_id", "prepare_receipt_id must be the 32-character receipt returned by metadata.saved_state.ensure.") + allowed, allowed_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) + if allowed_error: + return allowed_error + if not allowed: + return invalid_argument(method, "allow_sql_saved_state_rollback", "Rollback is opt-in; pass allow_sql_saved_state_rollback=true after verifying the test result.") + timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=60, minimum=1, maximum=240) + if timeout_error: + return timeout_error + result = rollback_saved_state_prepare_receipt(base_id_or_error, receipt_id, timeout_seconds=int(timeout_seconds or 60)) + return { + "schema": "onec_saved_state_ensure_rollback.v1", + "method": method, + "base_id": base_id_or_error, + "prepare_receipt_id": receipt_id, + **result, + } + + def saved_state_diff_public_module_target( payload: dict[str, Any], base_id: str, @@ -18425,6 +22007,136 @@ def saved_state_diff_public_module_target( } +def bsl_module_text_comparison( + active_text: str, + saved_text: str, + *, + routine_name: str = "", + include_text_diff: bool = True, + max_text_diff_lines: int = 200, +) -> dict[str, Any]: + active_normalized = str(active_text or "").replace("\r\n", "\n").replace("\r", "\n") + saved_normalized = str(saved_text or "").replace("\r\n", "\n").replace("\r", "\n") + scope = "module" + selection: dict[str, Any] = {} + if routine_name: + active_selected, active_selection = _extract_bsl_routine_text_for_code_read(active_normalized, routine_name) + saved_selected, saved_selection = _extract_bsl_routine_text_for_code_read(saved_normalized, routine_name) + if active_selected and saved_selected: + active_normalized = active_selected + saved_normalized = saved_selected + scope = "routine" + selection = { + "routine_name": routine_name, + "active": active_selection, + "saved": saved_selection, + } + active_sha1 = code_text_sha1(active_normalized) + saved_sha1 = code_text_sha1(saved_normalized) + diff_lines: list[str] = [] + if include_text_diff and active_sha1 != saved_sha1 and int(max_text_diff_lines or 0) > 0: + diff_lines = list( + difflib.unified_diff( + active_normalized.splitlines(), + saved_normalized.splitlines(), + fromfile="active", + tofile="saved", + lineterm="", + n=3, + ) + )[: int(max_text_diff_lines or 200)] + return { + "scope": scope, + "same": active_sha1 == saved_sha1, + "differs": active_sha1 != saved_sha1, + "active_sha1": active_sha1, + "saved_sha1": saved_sha1, + "active_chars": len(active_normalized), + "saved_chars": len(saved_normalized), + "diff_lines": diff_lines, + "diff_truncated": bool(diff_lines) and len(diff_lines) >= int(max_text_diff_lines or 0), + **({"selection": selection} if selection else {}), + } + + +def saved_state_module_text_comparison_from_bytes( + *, + active_bytes: bytes, + saved_bytes: bytes, + active_module_ref: str, + saved_module_ref: str, + routine_name: str, + bsl_offset: int | None, + include_text_diff: bool, + max_text_diff_lines: int, +) -> dict[str, Any] | None: + active_text, active_extraction = extract_code_index_text_from_payload( + active_bytes, + module_ref=active_module_ref, + bsl_offset=bsl_offset, + ) + saved_text, saved_extraction = extract_code_index_text_from_payload( + saved_bytes, + module_ref=saved_module_ref, + bsl_offset=bsl_offset, + ) + if active_extraction.get("status") != "ok" or saved_extraction.get("status") != "ok": + return None + return { + **bsl_module_text_comparison( + active_text, + saved_text, + routine_name=routine_name, + include_text_diff=include_text_diff, + max_text_diff_lines=max_text_diff_lines, + ), + "active_extraction": active_extraction, + "saved_extraction": saved_extraction, + } + + +def saved_state_module_text_comparison_from_live_read( + payload: dict[str, Any], + *, + base_id: str, + module_ref: str, + timeout_seconds: int, + max_text_diff_lines: int, +) -> dict[str, Any] | None: + compare_payload = { + key: value + for key, value in payload.items() + if key not in {"table", "target_table", "source_table", "file_name", "configuration_view"} + } + compare_result = code_read( + { + **compare_payload, + "base_id": base_id, + "module_ref": module_ref, + "state": "both", + "include_text": True, + "max_chars": 10_000_000, + "timeout_seconds": timeout_seconds, + } + ) + layers = compare_result.get("layers") if isinstance(compare_result.get("layers"), list) else [] + saved_layer = next((item for item in layers if isinstance(item, dict) and item.get("source") == "saved_state"), None) + active_layer = next((item for item in layers if isinstance(item, dict) and item.get("source") == "active"), None) + if not isinstance(saved_layer, dict) or not isinstance(active_layer, dict): + return None + if not isinstance(saved_layer.get("text"), str) or not isinstance(active_layer.get("text"), str): + return None + comparison = bsl_module_text_comparison( + active_layer["text"], + saved_layer["text"], + routine_name=str(payload.get("routine_name") or ""), + include_text_diff=bool(payload.get("include_text_diff", True)), + max_text_diff_lines=max_text_diff_lines, + ) + comparison["counterpart_resolution"] = active_layer.get("counterpart_resolution") + return comparison + + def metadata_saved_state_diff(payload: dict[str, Any]) -> dict[str, Any]: method = SAVED_STATE_DIFF_METHOD payload = normalize_object_selector_aliases(payload, method) @@ -18537,6 +22249,55 @@ def metadata_saved_state_diff(payload: dict[str, Any]) -> dict[str, Any]: } active_bytes, active_config, active_error = read_storage_file_bytes(base_id, source_table, file_name, timeout_seconds=int(timeout_seconds or 30)) if active_error: + live_module_comparison = ( + saved_state_module_text_comparison_from_live_read( + payload, + base_id=base_id, + module_ref=module_ref, + timeout_seconds=int(timeout_seconds or 30), + max_text_diff_lines=int(max_text_diff_lines or 200), + ) + if module_ref + else None + ) + if live_module_comparison: + module_changed = live_module_comparison.get("differs") is True + return { + "schema": "onec_saved_state_diff.v1", + "method": method, + "status": "changed" if module_changed else "unchanged", + "base_id": base_id, + "target": target, + "source": { + "kind": "live_metadata", + "active": {"state": "active"}, + "saved": {"state": "saved"}, + }, + "current_state": { + "source": "saved_state" if module_changed else "both", + "activation_state": "not_activated" if module_changed else "same_as_active", + }, + "needs_prepare": False, + "comparison": { + "differs": module_changed, + "text_same": live_module_comparison.get("same"), + "module_text_same": live_module_comparison.get("same"), + "container_text_same": None, + "tree_same": None, + "strings_same": None, + }, + "module_text": live_module_comparison, + "freshness": { + "source": "live_sql", + "status": "live_sql_verified", + "verified_against_sql": True, + "saved_payload_sha1": hashlib.sha1(saved_bytes or b"").hexdigest(), + "active_payload_sha1": None, + }, + "diagnostics": { + "message": "The active payload used a different physical reference; BSL was compared through the resolved logical module counterpart.", + }, + } status = "not_found" if active_error.get("status") == "source_missing" else active_error.get("status") or "error" return { "schema": "onec_saved_state_diff.v1", @@ -18585,6 +22346,21 @@ def metadata_saved_state_diff(payload: dict[str, Any]) -> dict[str, Any]: "needs_prepare": False, "diagnostics": diff.get("diagnostics") or diff, } + module_text_comparison = None + if module_ref: + active_module_ref = code_read_active_counterpart_module_ref(module_ref) + if active_module_ref: + module_text_comparison = saved_state_module_text_comparison_from_bytes( + active_bytes=active_bytes or b"", + saved_bytes=saved_bytes or b"", + active_module_ref=active_module_ref, + saved_module_ref=module_ref, + routine_name=str(payload.get("routine_name") or ""), + bsl_offset=int(payload.get("bsl_offset")) if payload.get("bsl_offset") not in {None, ""} else None, + include_text_diff=bool(payload.get("include_text_diff", True)), + max_text_diff_lines=int(max_text_diff_lines or 200), + ) + container_text_same = (diff.get("text") or {}).get("same") if isinstance(diff.get("text"), dict) else None result: dict[str, Any] = { "schema": "onec_saved_state_diff.v1", "method": method, @@ -18626,10 +22402,13 @@ def metadata_saved_state_diff(payload: dict[str, Any]) -> dict[str, Any]: "sha1": diff.get("sha1"), "comparison": { "differs": diff.get("status") == "changed", - "text_same": (diff.get("text") or {}).get("same") if isinstance(diff.get("text"), dict) else None, + "text_same": module_text_comparison.get("same") if module_text_comparison else container_text_same, + "module_text_same": module_text_comparison.get("same") if module_text_comparison else None, + "container_text_same": container_text_same, "tree_same": (diff.get("tree") or {}).get("same") if isinstance(diff.get("tree"), dict) else None, "strings_same": (diff.get("strings") or {}).get("same") if isinstance(diff.get("strings"), dict) else None, }, + **({"module_text": module_text_comparison} if module_text_comparison else {}), "text": diff.get("text"), "tree": diff.get("tree"), "strings": diff.get("strings"), @@ -18856,6 +22635,1441 @@ def metadata_saved_state_status(payload: dict[str, Any]) -> dict[str, Any]: } +def configuration_activation_layers(payload: dict[str, Any]) -> list[str]: + layer = str(payload.get("layer") or "all").strip() + if layer == "base_saved_state": + return ["base_saved_state"] + if layer == "extension_saved_state": + return ["extension_saved_state"] + return ["base_saved_state", "extension_saved_state"] + + +def configuration_activation_status(payload: dict[str, Any]) -> dict[str, Any]: + """Report the live saved-state/active boundary without activating anything.""" + + method = CONFIGURATION_ACTIVATION_STATUS_METHOD + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + timeout_seconds, timeout_error = parse_int_argument( + payload, + "timeout_seconds", + method=method, + default=30, + minimum=1, + ) + if timeout_error: + return timeout_error + limit, limit_error = parse_int_argument( + payload, + "limit", + method=method, + default=5000, + minimum=1, + maximum=5000, + ) + if limit_error: + return limit_error + include_files, include_files_error = strict_bool_argument( + payload, + "include_files", + method=method, + default=False, + ) + if include_files_error: + return include_files_error + include_storage, include_storage_error = strict_include_storage(payload, method) + if include_storage_error: + return include_storage_error + include_storage = bool(include_storage) + + layers: list[dict[str, Any]] = [] + upstream_errors: list[dict[str, Any]] = [] + total_changed = 0 + total_saved_only = 0 + total_changed_parts = 0 + scan_complete = True + for layer in configuration_activation_layers(payload): + status_result = metadata_saved_state_status( + { + "base_id": base_id, + "layer": layer, + "limit": int(limit or 5000), + "timeout_seconds": int(timeout_seconds or 30), + "include_files": bool(include_files and include_storage), + "include_unchanged": False, + "include_storage": include_storage, + } + ) + result_status = str(status_result.get("status") or "") + if result_status not in {"empty", "unchanged", "changed"}: + upstream_errors.append( + { + "layer": layer, + "status": result_status or "error", + "error": status_result.get("error"), + "diagnostics": status_result.get("diagnostics"), + } + ) + continue + counts = status_result.get("counts") if isinstance(status_result.get("counts"), dict) else {} + changed = int(counts.get("changed_files", counts.get("changed_objects", 0)) or 0) + saved_only = int(counts.get("saved_only_files", counts.get("saved_only_objects", 0)) or 0) + changed_parts = int(counts.get("changed_rows", counts.get("changed_parts", 0)) or 0) + saved_records = int(counts.get("saved_rows", counts.get("saved_records", 0)) or 0) + layer_scan_complete = saved_records < int(limit or 5000) + pending = changed + saved_only + total_changed += changed + total_saved_only += saved_only + total_changed_parts += changed_parts + scan_complete = scan_complete and layer_scan_complete + layer_result: dict[str, Any] = { + "layer": layer, + "active_layer": SAVED_STATE_ACTIVE_LAYER_BY_TABLE[SAVED_STATE_TABLE_BY_LAYER[layer]], + "status": "activation_required" if pending else ("up_to_date" if layer_scan_complete else "inconclusive"), + "activation_required": bool(pending), + "scan_complete": layer_scan_complete, + "counts": { + "changed_objects": changed, + "saved_only_objects": saved_only, + "pending_objects": pending, + "changed_parts": changed_parts, + "saved_records_checked": saved_records, + }, + "freshness": status_result.get("freshness"), + } + if include_storage: + layer_result["storage"] = { + "source": status_result.get("source"), + "query": status_result.get("query"), + "files": status_result.get("files") if include_files else None, + } + layers.append(layer_result) + + if upstream_errors: + overall_status = "error" + activation_required: bool | None = None + elif total_changed or total_saved_only: + overall_status = "activation_required" + activation_required = True + elif not scan_complete: + overall_status = "inconclusive" + activation_required = None + else: + overall_status = "up_to_date" + activation_required = False + return { + "schema": "onec_configuration_activation_status.v1", + "method": method, + "status": overall_status, + "base_id": base_id, + "activation_required": activation_required, + "boundary": { + "working": "saved_state", + "runtime": "active", + "direction": "saved_state_to_active", + }, + "scope": { + "layer": str(payload.get("layer") or "all").strip(), + "layers_checked": [item["layer"] for item in layers], + }, + "counts": { + "changed_objects": total_changed, + "saved_only_objects": total_saved_only, + "pending_objects": total_changed + total_saved_only, + "changed_parts": total_changed_parts, + }, + "layers": layers, + "freshness": { + "source": "live_sql", + "status": "live_verified" if not upstream_errors else "error", + "uses_cache": False, + "uses_vector_index": False, + "scan_complete": scan_complete and not upstream_errors, + }, + "execution": { + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + }, + "diagnostics": { + "read_only": True, + "upstream_errors": upstream_errors, + "note": ( + "This method reports the activation boundary only. It does not update the database " + "configuration and does not start 1C Designer." + ), + }, + } + + +def configuration_activation_plan(payload: dict[str, Any]) -> dict[str, Any]: + """Build a reviewable handoff plan; never execute 1C Designer.""" + + method = CONFIGURATION_ACTIVATION_PLAN_METHOD + status_payload = dict(payload) + status_result = configuration_activation_status(status_payload) + if status_result.get("schema") != "onec_configuration_activation_status.v1": + return status_result + base_id = str(status_result.get("base_id") or "") + activation_required = status_result.get("activation_required") + status = str(status_result.get("status") or "") + layer = str((status_result.get("scope") or {}).get("layer") or "all") + verification_payload = { + "base_id": base_id, + "layer": layer, + "limit": int(payload.get("limit") or 5000), + } + review_payload: dict[str, Any] = { + "base_id": base_id, + "include_context": True, + "group_by_context": True, + "context_limit": 100, + } + if layer in {"base_saved_state", "extension_saved_state"}: + review_payload["layer"] = layer + if status == "error": + plan_status = "blocked" + next_step: dict[str, Any] | None = { + "kind": "adapter_call", + "method": CONFIGURATION_ACTIVATION_STATUS_METHOD, + "params": verification_payload, + "reason": "live_status_failed", + } + elif status == "inconclusive": + plan_status = "blocked" + next_step = { + "kind": "adapter_call", + "method": CONFIGURATION_ACTIVATION_STATUS_METHOD, + "params": verification_payload, + "reason": "saved_state_scan_incomplete", + } + elif activation_required: + plan_status = "manual_activation_required" + next_step = { + "kind": "manual_1c_designer_action", + "action": "update_database_configuration", + "presentation": "Открыть базу в Конфигураторе и выполнить «Обновить конфигурацию базы данных» (F7).", + "base_id": base_id, + "scope": status_result.get("scope"), + "confirmation_required": True, + } + else: + plan_status = "not_required" + next_step = None + return { + "schema": "onec_configuration_activation_plan.v1", + "method": method, + "status": plan_status, + "base_id": base_id, + "activation_required": activation_required, + "activation_status": status_result, + "bridge": { + "mode": "status_plan_and_debug_acceptance", + "external_1c_enabled": repository_control.external_1c_enabled(), + "debug_acceptance_available": True, + "activation_execution_available": False, + "reason": "designer_activation_bridge_not_implemented", + }, + "review_call": { + "method": SAVED_STATE_CHANGES_LIST_METHOD, + "params": review_payload, + } + if activation_required + else None, + "debug_request_call": { + "method": CONFIGURATION_ACTIVATION_REQUEST_METHOD, + "params": { + "base_id": base_id, + "layer": layer, + "limit": int(payload.get("limit") or 5000), + }, + } + if activation_required + else None, + "next_step": next_step, + "verification_call": { + "method": CONFIGURATION_ACTIVATION_STATUS_METHOD, + "params": verification_payload, + "expected_status_after_activation": "up_to_date", + } + if activation_required + else None, + "execution": { + "performed": False, + "allowed": False, + "confirmation_required_for_future_execution": True, + "active_configuration_changed": False, + }, + "diagnostics": { + "read_only": True, + "note": ( + "The plan is an explicit handoff boundary. A future execution method must use separate " + "confirmation and a dedicated 1C Designer bridge." + ), + }, + } + + +def configuration_activation_state_path() -> Path: + """Legacy JSON path used only for one-time migration to local SQLite.""" + + return Path( + os.environ.get("ONEC_CONFIGURATION_ACTIVATION_STATE_FILE") + or "/data/onec-configuration-activation-requests.json" + ) + + +def configuration_activation_load_legacy_state() -> dict[str, Any]: + path = configuration_activation_state_path() + if not path.exists(): + return {"schema": "onec_configuration_activation_requests.v1", "requests": {}} + try: + value = json.loads(path.read_text(encoding="utf-8-sig")) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return {"schema": "onec_configuration_activation_requests.v1", "requests": {}} + if not isinstance(value, dict): + return {"schema": "onec_configuration_activation_requests.v1", "requests": {}} + requests = value.get("requests") + value["requests"] = requests if isinstance(requests, dict) else {} + value["schema"] = "onec_configuration_activation_requests.v1" + return value + + +def configuration_activation_sync_state( + conn: sqlite3.Connection, + state: dict[str, Any], +) -> None: + safe_state = adapter_state_safe_value(state) + requests = safe_state.get("requests") if isinstance(safe_state, dict) else {} + requests = requests if isinstance(requests, dict) else {} + conn.execute("DELETE FROM configuration_activation_events") + conn.execute("DELETE FROM configuration_activation_requests") + for request_id, raw_request in requests.items(): + if not isinstance(raw_request, dict): + continue + request = dict(raw_request) + events = request.pop("events", []) + created_at = float(request.get("created_at") or adapter_now()) + expires_at = float(request.get("expires_at") or created_at) + updated_candidates = [ + request.get(key) + for key in ( + "updated_at", + "verified_at", + "last_verified_at", + "changed_since_request_at", + "accepted_at", + "cancelled_at", + "expired_at", + "stale_at", + "created_at", + ) + if request.get(key) is not None + ] + updated_at = max((float(value) for value in updated_candidates), default=created_at) + conn.execute( + """ + INSERT INTO configuration_activation_requests( + request_id, base_id, layer, fingerprint, status, + created_at, expires_at, updated_at, payload_json + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + str(request_id), + str(request.get("base_id") or ""), + str(request.get("layer") or "all"), + str(request.get("fingerprint") or ""), + str(request.get("status") or "unknown"), + created_at, + expires_at, + updated_at, + json.dumps(request, ensure_ascii=False, separators=(",", ":"), default=str), + ), + ) + for event in events if isinstance(events, list) else []: + if not isinstance(event, dict): + continue + occurred_at = float(event.get("at") or updated_at) + details = { + key: value + for key, value in event.items() + if key not in {"event", "at"} + } + conn.execute( + """ + INSERT INTO configuration_activation_events( + request_id, event, occurred_at, details_json + ) VALUES(?, ?, ?, ?) + """, + ( + str(request_id), + str(event.get("event") or "unknown"), + occurred_at, + json.dumps(details, ensure_ascii=False, separators=(",", ":"), default=str), + ), + ) + + +def configuration_activation_read_state_from_connection( + conn: sqlite3.Connection, +) -> dict[str, Any]: + result = {"schema": "onec_configuration_activation_requests.v1", "requests": {}} + migration_row = conn.execute( + "SELECT value FROM adapter_state_meta " + "WHERE key='legacy_activation_requests_imported'" + ).fetchone() + if not migration_row: + existing = conn.execute( + "SELECT COUNT(*) AS count FROM configuration_activation_requests" + ).fetchone() + if not existing or int(existing["count"] or 0) == 0: + legacy = configuration_activation_load_legacy_state() + if (legacy.get("requests") or {}): + configuration_activation_sync_state(conn, legacy) + conn.execute( + """ + INSERT OR REPLACE INTO adapter_state_meta(key, value, updated_at) + VALUES('legacy_activation_requests_imported', ?, ?) + """, + ("yes", adapter_now()), + ) + rows = conn.execute( + """ + SELECT request_id, payload_json + FROM configuration_activation_requests + ORDER BY created_at, request_id + """ + ).fetchall() + for row in rows: + try: + request = json.loads(str(row["payload_json"] or "")) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(request, dict): + request["events"] = [] + result["requests"][str(row["request_id"])] = request + event_rows = conn.execute( + """ + SELECT request_id, event, occurred_at, details_json + FROM configuration_activation_events + ORDER BY event_id + """ + ).fetchall() + for row in event_rows: + request = result["requests"].get(str(row["request_id"])) + if not isinstance(request, dict): + continue + try: + details = json.loads(str(row["details_json"] or "{}")) + except (json.JSONDecodeError, TypeError): + details = {} + event = { + "event": str(row["event"] or "unknown"), + "at": float(row["occurred_at"] or 0), + **(details if isinstance(details, dict) else {}), + } + request["events"].append(event) + return result + + +def configuration_activation_read_state() -> dict[str, Any]: + try: + with adapter_state_connection() as conn: + state = configuration_activation_read_state_from_connection(conn) + conn.commit() + return state + except Exception: + return {"schema": "onec_configuration_activation_requests.v1", "requests": {}} + + +def configuration_activation_write_state(state: dict[str, Any]) -> None: + with adapter_state_connection() as conn: + configuration_activation_sync_state(conn, state) + conn.execute( + """ + INSERT OR REPLACE INTO adapter_state_meta(key, value, updated_at) + VALUES('legacy_activation_requests_imported', ?, ?) + """, + ("yes", adapter_now()), + ) + conn.commit() + + +@contextmanager +def configuration_activation_state_transaction(): + """Serialize one activation read/modify/write cycle across processes.""" + + with CONFIGURATION_ACTIVATION_STATE_LOCK: + with adapter_state_connection() as conn: + conn.execute("BEGIN IMMEDIATE") + try: + state = configuration_activation_read_state_from_connection(conn) + yield state + configuration_activation_sync_state(conn, state) + conn.execute( + """ + INSERT OR REPLACE INTO adapter_state_meta(key, value, updated_at) + VALUES('legacy_activation_requests_imported', ?, ?) + """, + ("yes", adapter_now()), + ) + conn.commit() + except Exception: + conn.rollback() + raise + + +def configuration_activation_status_fingerprint(status_result: dict[str, Any]) -> str: + layers = [] + for layer in status_result.get("layers") or []: + if not isinstance(layer, dict): + continue + storage = layer.get("storage") if isinstance(layer.get("storage"), dict) else {} + files = [] + for item in storage.get("files") or []: + if not isinstance(item, dict): + continue + files.append( + { + "file_name": item.get("file_name"), + "status": item.get("status"), + "changed_parts": item.get("changed_parts"), + "saved_sha1": item.get("saved_sha1"), + "active_sha1": item.get("active_sha1"), + } + ) + layers.append( + { + "layer": layer.get("layer"), + "status": layer.get("status"), + "counts": layer.get("counts"), + "files": sorted(files, key=lambda item: str(item.get("file_name") or "")), + } + ) + evidence = { + "base_id": status_result.get("base_id"), + "scope": status_result.get("scope"), + "counts": status_result.get("counts"), + "layers": layers, + } + encoded = json.dumps( + evidence, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def configuration_activation_public_status(status_result: dict[str, Any]) -> dict[str, Any]: + public = copy.deepcopy(status_result) + for layer in public.get("layers") or []: + if isinstance(layer, dict): + layer.pop("storage", None) + return public + + +def configuration_activation_request(payload: dict[str, Any]) -> dict[str, Any]: + method = CONFIGURATION_ACTIVATION_REQUEST_METHOD + base_id = str(payload.get("base_id") or "").strip() + ttl_default = 1800 + try: + ttl_default = int(os.environ.get("ONEC_CONFIGURATION_ACTIVATION_REQUEST_TTL_SECONDS") or 1800) + except ValueError: + ttl_default = 1800 + ttl_default = min(86400, max(60, ttl_default)) + ttl_seconds, ttl_error = parse_int_argument( + payload, + "ttl_seconds", + method=method, + default=ttl_default, + minimum=60, + maximum=86400, + ) + if ttl_error: + return ttl_error + status_result = configuration_activation_status( + { + "base_id": base_id, + "layer": str(payload.get("layer") or "all"), + "limit": int(payload.get("limit") or 5000), + "timeout_seconds": int(payload.get("timeout_seconds") or 30), + "include_files": True, + "include_storage": True, + } + ) + public_status = configuration_activation_public_status(status_result) + if status_result.get("status") == "up_to_date": + return { + "schema": "onec_configuration_activation_request.v1", + "method": method, + "status": "not_required", + "base_id": base_id, + "activation_status": public_status, + "execution": {"performed": False, "active_configuration_changed": False}, + } + if status_result.get("status") != "activation_required": + return { + "schema": "onec_configuration_activation_request.v1", + "method": method, + "status": "blocked", + "base_id": base_id, + "error": "activation_status_not_ready", + "activation_status": public_status, + "execution": {"performed": False, "active_configuration_changed": False}, + } + now = time.time() + request_id = f"actreq-{uuid.uuid4().hex}" + fingerprint = configuration_activation_status_fingerprint(status_result) + request = { + "request_id": request_id, + "base_id": base_id, + "layer": str(payload.get("layer") or "all"), + "fingerprint": fingerprint, + "created_at": now, + "expires_at": now + int(ttl_seconds or ttl_default), + "status": "pending", + "events": [{"event": "requested", "at": now}], + } + with configuration_activation_state_transaction() as state: + state["requests"][request_id] = request + return { + "schema": "onec_configuration_activation_request.v1", + "method": method, + "status": "pending", + "base_id": base_id, + "request_id": request_id, + "layer": request["layer"], + "fingerprint": fingerprint, + "created_at": datetime.fromtimestamp(now, timezone.utc).isoformat(), + "expires_at": datetime.fromtimestamp(request["expires_at"], timezone.utc).isoformat(), + "activation_status": public_status, + "next_call": { + "method": CONFIGURATION_ACTIVATION_EXECUTE_METHOD, + "params": { + "base_id": base_id, + "request_id": request_id, + "mode": "debug", + "confirm_activation": True, + }, + }, + "execution": { + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + }, + } + + +def configuration_activation_request_status(payload: dict[str, Any]) -> dict[str, Any]: + method = CONFIGURATION_ACTIVATION_REQUEST_STATUS_METHOD + request_id = str(payload.get("request_id") or "").strip() + expected_base_id = str(payload.get("base_id") or "").strip() + with configuration_activation_state_transaction() as state: + request = state["requests"].get(request_id) + if not isinstance(request, dict): + return { + "schema": "onec_configuration_activation_request_status.v1", + "method": method, + "status": "not_found", + "request_id": request_id, + } + if expected_base_id and expected_base_id != str(request.get("base_id") or ""): + return { + "schema": "onec_configuration_activation_request_status.v1", + "method": method, + "status": "blocked", + "error": "base_id_mismatch", + "request_id": request_id, + } + if request.get("status") == "pending" and time.time() >= float(request.get("expires_at") or 0): + request["status"] = "expired" + request["expired_at"] = time.time() + request.setdefault("events", []).append({"event": "expired", "at": request["expired_at"]}) + public = { + key: value + for key, value in request.items() + if key + in { + "request_id", + "base_id", + "layer", + "fingerprint", + "status", + "created_at", + "expires_at", + "accepted_at", + "expired_at", + "verified_at", + "last_verified_at", + "changed_since_request_at", + "events", + } + } + for key in ( + "created_at", + "expires_at", + "accepted_at", + "expired_at", + "verified_at", + "last_verified_at", + "changed_since_request_at", + ): + if public.get(key) is not None: + public[key] = datetime.fromtimestamp(float(public[key]), timezone.utc).isoformat() + public["events"] = [ + { + **event, + "at": datetime.fromtimestamp(float(event["at"]), timezone.utc).isoformat(), + } + for event in public.get("events") or [] + if isinstance(event, dict) and event.get("at") is not None + ] + return { + "schema": "onec_configuration_activation_request_status.v1", + "method": method, + **public, + } + + +def configuration_activation_request_cancel(payload: dict[str, Any]) -> dict[str, Any]: + method = CONFIGURATION_ACTIVATION_REQUEST_CANCEL_METHOD + request_id = str(payload.get("request_id") or "").strip() + expected_base_id = str(payload.get("base_id") or "").strip() + if payload.get("confirm_cancel") is not True: + return { + "schema": "onec_configuration_activation_request_cancel.v1", + "method": method, + "status": "confirmation_required", + "error": "confirm_cancel_required", + "request_id": request_id, + } + with configuration_activation_state_transaction() as state: + request = state["requests"].get(request_id) + if not isinstance(request, dict): + return { + "schema": "onec_configuration_activation_request_cancel.v1", + "method": method, + "status": "not_found", + "request_id": request_id, + } + if expected_base_id and expected_base_id != str(request.get("base_id") or ""): + return { + "schema": "onec_configuration_activation_request_cancel.v1", + "method": method, + "status": "blocked", + "error": "base_id_mismatch", + "request_id": request_id, + } + if request.get("status") == "cancelled": + cancelled_at = float(request.get("cancelled_at") or time.time()) + else: + cancelled_at = time.time() + previous_status = str(request.get("status") or "unknown") + request["status"] = "cancelled" + request["cancelled_at"] = cancelled_at + request.setdefault("events", []).append( + { + "event": "cancelled", + "at": cancelled_at, + "previous_status": previous_status, + } + ) + return { + "schema": "onec_configuration_activation_request_cancel.v1", + "method": method, + "status": "cancelled", + "base_id": request.get("base_id"), + "request_id": request_id, + "previous_status": previous_status if "previous_status" in locals() else "cancelled", + "cancelled_at": datetime.fromtimestamp(cancelled_at, timezone.utc).isoformat(), + "execution": { + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + }, + } + + +def configuration_activation_audit(payload: dict[str, Any]) -> dict[str, Any]: + method = CONFIGURATION_ACTIVATION_AUDIT_METHOD + base_id = str(payload.get("base_id") or "").strip() + limit = int(payload.get("limit") or 100) + status_filter = str(payload.get("status") or "").strip() + now = time.time() + changed = False + with configuration_activation_state_transaction() as state: + requests = [] + for request in state["requests"].values(): + if not isinstance(request, dict) or str(request.get("base_id") or "") != base_id: + continue + if request.get("status") == "pending" and now >= float(request.get("expires_at") or 0): + request["status"] = "expired" + request["expired_at"] = now + request.setdefault("events", []).append({"event": "expired", "at": now}) + changed = True + if status_filter and str(request.get("status") or "") != status_filter: + continue + requests.append(copy.deepcopy(request)) + requests.sort(key=lambda item: float(item.get("created_at") or 0), reverse=True) + requests = requests[:limit] + public_requests = [] + for request in requests: + public_events = [] + for event in request.get("events") or []: + if not isinstance(event, dict): + continue + public_event = { + key: value + for key, value in event.items() + if key in {"event", "at", "previous_status"} + } + if public_event.get("at") is not None: + public_event["at"] = datetime.fromtimestamp( + float(public_event["at"]), + timezone.utc, + ).isoformat() + public_events.append(public_event) + public_request = { + key: value + for key, value in request.items() + if key + in { + "request_id", + "base_id", + "layer", + "status", + "created_at", + "expires_at", + "accepted_at", + "expired_at", + "cancelled_at", + "stale_at", + "verified_at", + "last_verified_at", + "changed_since_request_at", + } + } + for key in ( + "created_at", + "expires_at", + "accepted_at", + "expired_at", + "cancelled_at", + "stale_at", + "verified_at", + "last_verified_at", + "changed_since_request_at", + ): + if public_request.get(key) is not None: + public_request[key] = datetime.fromtimestamp( + float(public_request[key]), + timezone.utc, + ).isoformat() + public_request["events"] = public_events + public_requests.append(public_request) + status_counts: dict[str, int] = {} + for request in public_requests: + request_status = str(request.get("status") or "unknown") + status_counts[request_status] = status_counts.get(request_status, 0) + 1 + return { + "schema": "onec_configuration_activation_audit.v1", + "method": method, + "status": "ok", + "base_id": base_id, + "query": { + "limit": limit, + "status": status_filter or None, + }, + "count": len(public_requests), + "status_counts": status_counts, + "requests": public_requests, + "diagnostics": { + "source": "adapter_local_sqlite", + "fingerprint_evidence_exposed": False, + "active_configuration_read": False, + }, + } + + +def configuration_activation_capabilities(payload: dict[str, Any]) -> dict[str, Any]: + method = CONFIGURATION_ACTIVATION_CAPABILITIES_METHOD + base_id = str(payload.get("base_id") or "").strip() + layer = str(payload.get("layer") or "all").strip() + config, config_error = repository_control.repository_config(base_id, "base") + runner = config.get("runner") if isinstance(config, dict) and isinstance(config.get("runner"), dict) else {} + infobase = config.get("infobase") if isinstance(config, dict) and isinstance(config.get("infobase"), dict) else {} + runner_kind = str(runner.get("kind") or "") if config else None + if runner_kind == "local": + runner_ready = bool( + str(config.get("designer_path") or "").strip() + and sum(bool(str(infobase.get(key) or "").strip()) for key in ("file", "server", "name")) == 1 + ) + elif runner_kind == "http": + runner_ready = bool(str(runner.get("url") or "").strip()) + else: + runner_ready = False + return { + "schema": "onec_configuration_activation_capabilities.v1", + "method": method, + "status": "ok", + "base_id": base_id, + "scope": {"layer": layer}, + "workflow": { + "status": True, + "plan": True, + "fingerprinted_request": True, + "request_status": True, + "request_cancel": True, + "audit": True, + "bridge_probe": True, + "debug_acceptance": True, + "post_activation_verify": True, + "real_execution": False, + }, + "layers": { + "base_saved_state": { + "status": "debug_only", + "designer_operation": "/UpdateDBCfg", + "operation_documented": True, + "execution_implemented": False, + }, + "extension_saved_state": { + "status": "manual_only", + "designer_operation": None, + "operation_documented": False, + "execution_implemented": False, + "reason": "verified_extension_activation_command_not_implemented", + }, + }, + "runner": { + "configured": config_error is None and isinstance(config, dict), + "kind": runner_kind, + "ready_for_future_base_activation": runner_ready, + "external_1c_enabled": repository_control.external_1c_enabled(), + "designer_path_configured": bool( + isinstance(config, dict) and str(config.get("designer_path") or "").strip() + ), + "infobase_selector_configured": bool( + isinstance(config, dict) + and sum(bool(str(infobase.get(key) or "").strip()) for key in ("file", "server", "name")) == 1 + ), + "problem": config_error, + }, + "bridge_probe_call": { + "method": CONFIGURATION_ACTIVATION_BRIDGE_PROBE_METHOD, + "params": {"base_id": base_id, "layer": layer}, + }, + "state_store": { + "kind": "local_sqlite", + "requests_table": "configuration_activation_requests", + "events_table": "configuration_activation_events", + "onec_database_used": False, + "legacy_json_write_enabled": False, + }, + "safety": { + "credentials_exposed": False, + "runner_url_exposed": False, + "designer_path_exposed": False, + "active_configuration_write_enabled": False, + "required_real_execution_gates": [ + "dedicated_enable_flag", + "fingerprinted_request", + "fresh_live_sql_recheck", + "explicit_confirmation", + "post_execution_live_verification", + ], + }, + } + + +def configuration_activation_bridge_probe(payload: dict[str, Any]) -> dict[str, Any]: + method = CONFIGURATION_ACTIVATION_BRIDGE_PROBE_METHOD + base_id = str(payload.get("base_id") or "").strip() + layer = str(payload.get("layer") or "all").strip() + timeout_seconds = int(payload.get("timeout_seconds") or 10) + config, config_error = repository_control.repository_config(base_id, "base") + if config_error or not isinstance(config, dict): + return { + "schema": "onec_configuration_activation_bridge_probe.v1", + "method": method, + "status": "not_configured", + "base_id": base_id, + "layer": layer, + "problem": { + "status": (config_error or {}).get("status") or "not_configured", + }, + "execution": { + "mode": "debug", + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + }, + } + probe = repository_control.activation_debug_probe( + base_id, + config, + layer=layer, + timeout_seconds=timeout_seconds, + ) + runner = probe.get("runner") if isinstance(probe.get("runner"), dict) else {} + operation = probe.get("operation") if isinstance(probe.get("operation"), dict) else {} + probe_status = str(probe.get("status") or "runner_error") + public_status = probe_status if probe_status in {"ready", "not_ready"} else "runner_error" + return { + "schema": "onec_configuration_activation_bridge_probe.v1", + "method": method, + "status": public_status, + "base_id": base_id, + "layer": layer, + "runner": { + key: value + for key, value in runner.items() + if key + in { + "kind", + "reachable", + "designer_path_configured", + "designer_available", + "infobase_selector_configured", + } + }, + "operation": { + key: value + for key, value in operation.items() + if key in {"kind", "execution_supported", "extension_manual_only"} + }, + "external_1c_enabled": repository_control.external_1c_enabled(), + "execution": { + "mode": "debug", + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + }, + "diagnostics": { + "infobase_credentials_read": False, + "repository_credentials_read": False, + "runner_authentication_used": runner.get("kind") == "http", + "credentials_exposed": False, + "paths_exposed": False, + "urls_exposed": False, + "raw_runner_status": probe_status, + }, + } + + +def configuration_activation_execute(payload: dict[str, Any]) -> dict[str, Any]: + method = CONFIGURATION_ACTIVATION_EXECUTE_METHOD + base_id = str(payload.get("base_id") or "").strip() + request_id = str(payload.get("request_id") or "").strip() + mode = str(payload.get("mode") or "debug").strip().casefold() + if mode != "debug": + return { + "schema": "onec_configuration_activation_execute.v1", + "method": method, + "status": "blocked", + "error": "activation_execution_not_available", + "allowed_modes": ["debug"], + "execution": {"performed": False, "active_configuration_changed": False}, + } + if payload.get("confirm_activation") is not True: + return { + "schema": "onec_configuration_activation_execute.v1", + "method": method, + "status": "confirmation_required", + "error": "confirm_activation_required", + "request_id": request_id, + "execution": {"performed": False, "active_configuration_changed": False}, + } + with configuration_activation_state_transaction() as state: + request = state["requests"].get(request_id) + if not isinstance(request, dict): + return { + "schema": "onec_configuration_activation_execute.v1", + "method": method, + "status": "not_found", + "request_id": request_id, + "execution": {"performed": False, "active_configuration_changed": False}, + } + if base_id != str(request.get("base_id") or ""): + return { + "schema": "onec_configuration_activation_execute.v1", + "method": method, + "status": "blocked", + "error": "base_id_mismatch", + "request_id": request_id, + "execution": {"performed": False, "active_configuration_changed": False}, + } + if request.get("status") == "pending" and time.time() >= float(request.get("expires_at") or 0): + request["status"] = "expired" + request["expired_at"] = time.time() + request.setdefault("events", []).append({"event": "expired", "at": request["expired_at"]}) + if request.get("status") == "expired": + return { + "schema": "onec_configuration_activation_execute.v1", + "method": method, + "status": "blocked", + "error": "activation_request_expired", + "request_id": request_id, + "execution": {"performed": False, "active_configuration_changed": False}, + } + expected_fingerprint = str(request.get("fingerprint") or "") + layer = str(request.get("layer") or "all") + + status_result = configuration_activation_status( + { + "base_id": base_id, + "layer": layer, + "limit": 5000, + "timeout_seconds": int(payload.get("timeout_seconds") or 30), + "include_files": True, + "include_storage": True, + } + ) + public_status = configuration_activation_public_status(status_result) + if status_result.get("status") != "activation_required": + return { + "schema": "onec_configuration_activation_execute.v1", + "method": method, + "status": "blocked", + "error": "activation_status_changed", + "request_id": request_id, + "activation_status": public_status, + "next_call": { + "method": CONFIGURATION_ACTIVATION_REQUEST_METHOD, + "params": {"base_id": base_id, "layer": layer}, + }, + "execution": {"performed": False, "active_configuration_changed": False}, + } + current_fingerprint = configuration_activation_status_fingerprint(status_result) + if not hmac.compare_digest(expected_fingerprint, current_fingerprint): + with configuration_activation_state_transaction() as state: + stored = state["requests"].get(request_id) + if isinstance(stored, dict): + stored["status"] = "stale" + stored["stale_at"] = time.time() + stored.setdefault("events", []).append({"event": "fingerprint_changed", "at": stored["stale_at"]}) + return { + "schema": "onec_configuration_activation_execute.v1", + "method": method, + "status": "blocked", + "error": "activation_fingerprint_changed", + "request_id": request_id, + "expected_fingerprint": expected_fingerprint, + "current_fingerprint": current_fingerprint, + "activation_status": public_status, + "next_call": { + "method": CONFIGURATION_ACTIVATION_REQUEST_METHOD, + "params": {"base_id": base_id, "layer": layer}, + }, + "execution": {"performed": False, "active_configuration_changed": False}, + } + bridge_debug_requested = payload.get("bridge_debug") is True + bridge_debug_result = None + if bridge_debug_requested: + config, config_error = repository_control.repository_config(base_id, "base") + if config_error or not isinstance(config, dict): + return { + "schema": "onec_configuration_activation_execute.v1", + "method": method, + "status": "blocked", + "error": "activation_bridge_not_configured", + "request_id": request_id, + "bridge": { + "status": (config_error or {}).get("status") or "not_configured", + "debug_requested": True, + }, + "execution": { + "mode": "debug", + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + }, + } + bridge_probe = repository_control.activation_debug_probe( + base_id, + config, + layer=layer, + timeout_seconds=int(payload.get("timeout_seconds") or 30), + request_id=request_id, + fingerprint=current_fingerprint, + ) + bridge_acceptance = ( + bridge_probe.get("debug_acceptance") + if isinstance(bridge_probe.get("debug_acceptance"), dict) + else {} + ) + bridge_receipt = str(bridge_acceptance.get("receipt") or "").strip().casefold() + bridge_request_id = str(bridge_acceptance.get("request_id") or "") + bridge_fingerprint = str(bridge_acceptance.get("fingerprint") or "").strip().casefold() + bridge_ready = str(bridge_probe.get("status") or "") == "ready" + bridge_accepted = bridge_acceptance.get("accepted") is True + receipt_valid = bool( + re.fullmatch(r"[0-9a-f]{64}", bridge_receipt) + and hmac.compare_digest(bridge_request_id, request_id) + and hmac.compare_digest(bridge_fingerprint, current_fingerprint) + ) + if not bridge_ready or not bridge_accepted or not receipt_valid: + return { + "schema": "onec_configuration_activation_execute.v1", + "method": method, + "status": "blocked", + "error": "activation_bridge_debug_not_accepted", + "request_id": request_id, + "bridge": { + "status": str(bridge_probe.get("status") or "runner_error"), + "debug_requested": True, + "accepted": bridge_accepted, + "receipt_valid": receipt_valid, + }, + "execution": { + "mode": "debug", + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + }, + } + bridge_runner = ( + bridge_probe.get("runner") + if isinstance(bridge_probe.get("runner"), dict) + else {} + ) + bridge_debug_result = { + "status": "accepted", + "runner_kind": bridge_runner.get("kind"), + "request_id": request_id, + "fingerprint": current_fingerprint, + "receipt": bridge_receipt, + "execution_performed": False, + "designer_started": False, + } + accepted_at = time.time() + with configuration_activation_state_transaction() as state: + stored = state["requests"].get(request_id) + if isinstance(stored, dict): + stored["status"] = "debug_accepted" + stored["accepted_at"] = accepted_at + stored.setdefault("events", []).append({"event": "debug_accepted", "at": accepted_at}) + if bridge_debug_result: + stored["bridge_debug_receipt"] = bridge_debug_result["receipt"] + stored.setdefault("events", []).append({"event": "bridge_debug_accepted", "at": accepted_at}) + extension_in_scope = layer in {"all", "extension_saved_state"} + command_preview = { + "runner": "1c_designer", + "mode": "debug", + "operation": "manual_extension_database_configuration_update" + if extension_in_scope + else "/UpdateDBCfg", + "layer": layer, + "executable": False, + "reason": "extension_activation_cli_not_implemented" + if extension_in_scope + else "real_execution_disabled", + } + return { + "schema": "onec_configuration_activation_execute.v1", + "method": method, + "status": "debug_accepted", + "base_id": base_id, + "request_id": request_id, + "layer": layer, + "fingerprint": current_fingerprint, + "accepted_at": datetime.fromtimestamp(accepted_at, timezone.utc).isoformat(), + "activation_status": public_status, + "command_preview": command_preview, + "bridge_debug": bridge_debug_result + if bridge_debug_result + else { + "status": "not_requested", + "execution_performed": False, + "designer_started": False, + }, + "verification_call": { + "method": CONFIGURATION_ACTIVATION_VERIFY_METHOD, + "params": {"base_id": base_id, "request_id": request_id}, + "expected_status_after_real_activation": "verified_up_to_date", + }, + "execution": { + "mode": "debug", + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + }, + } + + +def configuration_activation_verify(payload: dict[str, Any]) -> dict[str, Any]: + method = CONFIGURATION_ACTIVATION_VERIFY_METHOD + base_id = str(payload.get("base_id") or "").strip() + request_id = str(payload.get("request_id") or "").strip() + timeout_seconds = int(payload.get("timeout_seconds") or 30) + with configuration_activation_state_transaction() as state: + request = state["requests"].get(request_id) + if not isinstance(request, dict): + return { + "schema": "onec_configuration_activation_verify.v1", + "method": method, + "status": "not_found", + "request_id": request_id, + "execution": {"performed": False, "active_configuration_changed": False}, + } + if base_id != str(request.get("base_id") or ""): + return { + "schema": "onec_configuration_activation_verify.v1", + "method": method, + "status": "blocked", + "error": "base_id_mismatch", + "request_id": request_id, + "execution": {"performed": False, "active_configuration_changed": False}, + } + request_status = str(request.get("status") or "") + if request_status in {"cancelled", "expired", "stale", "changed_since_request"}: + return { + "schema": "onec_configuration_activation_verify.v1", + "method": method, + "status": "blocked", + "error": f"activation_request_{request_status}", + "request_id": request_id, + "request_status": request_status, + "execution": {"performed": False, "active_configuration_changed": False}, + } + layer = str(request.get("layer") or "all") + expected_fingerprint = str(request.get("fingerprint") or "") + + status_result = configuration_activation_status( + { + "base_id": base_id, + "layer": layer, + "limit": 5000, + "timeout_seconds": timeout_seconds, + "include_files": True, + "include_storage": True, + } + ) + public_status = configuration_activation_public_status(status_result) + live_status = str(status_result.get("status") or "") + verified_at = time.time() + if live_status == "up_to_date": + with configuration_activation_state_transaction() as state: + stored = state["requests"].get(request_id) + if isinstance(stored, dict): + if stored.get("status") != "verified_up_to_date": + stored["status"] = "verified_up_to_date" + stored["verified_at"] = verified_at + stored.setdefault("events", []).append( + {"event": "verified_up_to_date", "at": verified_at} + ) + else: + verified_at = float(stored.get("verified_at") or verified_at) + return { + "schema": "onec_configuration_activation_verify.v1", + "method": method, + "status": "verified_up_to_date", + "base_id": base_id, + "request_id": request_id, + "layer": layer, + "verified_at": datetime.fromtimestamp(verified_at, timezone.utc).isoformat(), + "activation_status": public_status, + "outcome": { + "saved_and_active_aligned": True, + "activation_proven": False, + "reason": ( + "Live SQL proves that saved and active layers align. It cannot distinguish " + "Designer activation from another operation that removed or replaced saved changes." + ), + }, + "execution": { + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + }, + } + if live_status == "activation_required": + current_fingerprint = configuration_activation_status_fingerprint(status_result) + if hmac.compare_digest(expected_fingerprint, current_fingerprint): + with configuration_activation_state_transaction() as state: + stored = state["requests"].get(request_id) + if isinstance(stored, dict): + stored["last_verified_at"] = verified_at + stored.setdefault("events", []).append( + {"event": "verification_not_activated", "at": verified_at} + ) + return { + "schema": "onec_configuration_activation_verify.v1", + "method": method, + "status": "not_activated", + "base_id": base_id, + "request_id": request_id, + "layer": layer, + "activation_status": public_status, + "next_step": { + "kind": "manual_1c_designer_action", + "action": "update_database_configuration", + "presentation": ( + "Открыть базу в Конфигураторе и выполнить " + "«Обновить конфигурацию базы данных» (F7)." + ), + "confirmation_required": True, + }, + "retry_call": { + "method": method, + "params": { + "base_id": base_id, + "request_id": request_id, + "timeout_seconds": timeout_seconds, + }, + }, + "execution": { + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + }, + } + with configuration_activation_state_transaction() as state: + stored = state["requests"].get(request_id) + if isinstance(stored, dict): + stored["status"] = "changed_since_request" + stored["changed_since_request_at"] = verified_at + stored.setdefault("events", []).append( + {"event": "changed_since_request", "at": verified_at} + ) + return { + "schema": "onec_configuration_activation_verify.v1", + "method": method, + "status": "changed_since_request", + "base_id": base_id, + "request_id": request_id, + "layer": layer, + "activation_status": public_status, + "next_call": { + "method": CONFIGURATION_ACTIVATION_REQUEST_METHOD, + "params": {"base_id": base_id, "layer": layer}, + }, + "execution": { + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + }, + } + return { + "schema": "onec_configuration_activation_verify.v1", + "method": method, + "status": "blocked", + "error": "activation_status_not_verifiable", + "base_id": base_id, + "request_id": request_id, + "layer": layer, + "activation_status": public_status, + "execution": { + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + }, + } + + def metadata_saved_state_change_context( *, base_id: str, @@ -19616,7 +24830,7 @@ def write_storage_apply_backup( }, "rollback": { "method": "storage.saved_state.apply_proposal", - "payload": { + "request": { "base_id": base_id, "allow_sql_saved_state_apply": True, "proposal": { @@ -19710,6 +24924,67 @@ def semantic_verify_saved_state_apply( return result +def saved_state_apply_trace(*, phase: str, base_id: str, table: str, file_name: str) -> None: + """Emit coarse apply progress without logging payloads or credentials.""" + print( + json.dumps( + {"event": "saved_state_apply_phase", "phase": phase, "base_id": base_id, "table": table, "file_name": file_name, "time": datetime.now(timezone.utc).isoformat()}, + ensure_ascii=False, + ), + flush=True, + ) + + +def saved_state_apply_module_trace(*, phase: str, base_id: str, module_ref: str = "") -> None: + job_id = str(getattr(ADAPTER_JOB_CONTEXT, "job_id", "") or "") + if job_id: + adapter_job_set(job_id, current_step=phase, progress={"current_step": phase, "completed_steps": 0, "total_steps": 7, "percent": 0}) + print( + json.dumps( + {"event": "saved_state_module_apply_phase", "phase": phase, "base_id": base_id, "module_ref": module_ref, "time": datetime.now(timezone.utc).isoformat()}, + ensure_ascii=False, + ), + flush=True, + ) + + +def saved_state_configurator_refresh_guidance( + *, + table: str, + file_name: str, + object_existed_in_saved_state: bool, +) -> dict[str, Any]: + """Describe the minimal Configurator refresh after a SQL saved-state write. + + The SQL-only adapter can encode a verified saved-state overlay, but it cannot + alter the in-memory dirty flags of an already open Configurator session. + Keep this guidance factual: it is derived from the layer and the state that + existed *before* the write, not guessed from a 1C object name. + """ + extension_prefix, separator, _suffix = file_name.partition("__") + is_extension_file = bool(separator and is_guid_text(extension_prefix.strip().lower())) + if is_extension_file: + if object_existed_in_saved_state: + return { + "required": True, + "scope": "object", + "action": "close_reopen_object", + "reason": "The object already belonged to the extension saved-state; reopening the object reloads its saved overlay.", + } + return { + "required": True, + "scope": "extension", + "action": "close_reopen_extension", + "reason": "The adapter created an extension saved-state object; an open Configurator may still use the active extension until it is reopened.", + } + return { + "required": True, + "scope": "configuration", + "action": "close_reopen_configuration", + "reason": "The adapter created or changed a configuration saved-state object outside Configurator memory.", + } + + def apply_storage_file_bytes_single_part( base_id: str, table: str, @@ -19720,17 +24995,33 @@ def apply_storage_file_bytes_single_part( proposal: dict[str, Any], timeout_seconds: int = 30, ) -> dict[str, Any]: + phase = "connect" conn, config, error = connect_live_sql(base_id, "storage.saved_state.apply_proposal", timeout_seconds=timeout_seconds) if error: return error started = time.time() try: cursor = conn.cursor(as_dict=True) + phase = "set_lock_timeout" + saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) + # Connection timeout does not limit SQL Server lock waits. Without a + # session LOCK_TIMEOUT an UPDLOCK/HOLDLOCK read can leave a caller + # waiting indefinitely behind Configurator or another adapter write. + # Keep the requested bound inside SQL Server as well, before opening + # the transaction that protects the compare-and-set update. + # Pass an explicit empty parameter tuple for DB-API compatibility. + # The production SQL driver accepts it and lightweight DB-API + # adapters used by controlled tests require the parameters argument. + cursor.execute(f"SET LOCK_TIMEOUT {int(timeout_seconds or 30) * 1000}", ()) + phase = "acquire_update_lock" + saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) cursor.execute( f"SELECT PartNo, BinaryData FROM dbo.[{table}] WITH (UPDLOCK, HOLDLOCK) WHERE FileName = %s ORDER BY PartNo", (file_name,), ) rows = cursor.fetchall() + phase = "locked_read_complete" + saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) parts = [bytes(row.get("BinaryData")) for row in rows if isinstance(row.get("BinaryData"), (bytes, bytearray))] current = b"".join(parts) current_sha1 = hashlib.sha1(current).hexdigest() if current else "" @@ -19766,6 +25057,8 @@ def apply_storage_file_bytes_single_part( "original": {"sha1": current_sha1, "bytes": len(current), "parts": len(rows)}, "diagnostics": {"message": "Apply v1 only updates saved-state payloads stored as one SQL part. Multi-part replace needs a table-schema-aware writer."}, } + phase = "write_backup" + saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) backup = write_storage_apply_backup( base_id=base_id, config=config, @@ -19776,6 +25069,8 @@ def apply_storage_file_bytes_single_part( proposal=proposal, ) part_no = rows[0].get("PartNo") + phase = "update" + saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) cursor.execute( f"UPDATE dbo.[{table}] SET BinaryData = %s WHERE FileName = %s AND PartNo = %s", (replacement, file_name, part_no), @@ -19791,19 +25086,37 @@ def apply_storage_file_bytes_single_part( "backup": backup, "diagnostics": {"message": f"Expected to update exactly one row, updated {cursor.rowcount}."}, } + phase = "commit" + saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) conn.commit() + phase = "committed" + saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) except Exception as exc: try: conn.rollback() except Exception: pass + message = str(exc) + is_lock_timeout = "lock request time out" in message.casefold() or "lock timeout" in message.casefold() return { "schema": "onec_storage_saved_state_apply.v1", - "status": "error", + "status": "lock_timeout" if is_lock_timeout else "error", "applied": False, "base_id": base_id, "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": table, "file_name": file_name}, - "diagnostics": {"message": str(exc)}, + "diagnostics": { + "message": message, + "phase": phase, + **( + { + "code": "saved_state_lock_timeout", + "retry_after_seconds": int(timeout_seconds or 30), + "message": "Saved-state stream is locked by another SQL/Configurator operation; no write was performed.", + } + if is_lock_timeout + else {} + ), + }, } finally: try: @@ -19811,6 +25124,8 @@ def apply_storage_file_bytes_single_part( except Exception: pass + phase = "readback" + saved_state_apply_trace(phase=phase, base_id=base_id, table=table, file_name=file_name) readback, _read_config, read_error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=timeout_seconds) readback_sha1 = hashlib.sha1(readback).hexdigest() if readback else None encoded_sha1 = hashlib.sha1(replacement).hexdigest() @@ -19821,6 +25136,11 @@ def apply_storage_file_bytes_single_part( "applied": verified, "base_id": base_id, "source": {"kind": "live_sql", "database": config["database"], "table": table, "file_name": file_name}, + "configurator_refresh": saved_state_configurator_refresh_guidance( + table=table, + file_name=file_name, + object_existed_in_saved_state=bool(rows), + ), "backup": backup, "original": {"sha1": expected_sha1, "bytes": len(current), "parts": 1}, "encoded": {"sha1": encoded_sha1, "bytes": len(replacement)}, @@ -19842,6 +25162,8 @@ def apply_storage_file_bytes_single_part( result["cache_invalidation"] = invalidate_adapter_caches_after_saved_state_change( base_id, reason="saved_state_payload_apply", + source_table=table, + file_names=[file_name], ) return result @@ -19956,6 +25278,11 @@ def storage_file_get(payload: dict[str, Any]) -> dict[str, Any]: return diagnostic_error base_id = base_id_or_error table = table_or_error + active_route_error = reject_inactive_storage_file( + base_id, table, file_name, method="storage.file.get", timeout_seconds=int(timeout_seconds or 30) + ) + if active_route_error: + return active_route_error data, config, error = read_storage_file_bytes(base_id, table, file_name, timeout_seconds=int(timeout_seconds or 30)) if error: return error @@ -19971,6 +25298,197 @@ def storage_file_get(payload: dict[str, Any]) -> dict[str, Any]: return result +def apply_extension_saved_state_pair( + base_id: str, + *, + module_file_name: str, + replacement_module_data: bytes, + expected_module_sha1: str, + expected_configinfo_sha1: str | None = None, + proposal: dict[str, Any], + timeout_seconds: int = 30, +) -> dict[str, Any]: + """Atomically apply one existing extension stream and its ConfigInfo map. + + This writer intentionally supports only an existing single-part module + stream. Creating a missing overlay object needs the separate preparation + route because descriptor rows and the full pending manifest must be copied + first. + """ + extension_guid, separator, suffix = module_file_name.partition("__") + extension_guid = extension_guid.strip().lower() + if not separator or not suffix or not is_guid_text(extension_guid): + return { + "schema": "onec_extension_saved_state_pair_apply.v1", + "status": "invalid_extension_stream", + "applied": False, + "diagnostics": {"message": "module_file_name must be an extension ConfigCASSave stream."}, + } + if suffix.casefold() == "configinfo": + return { + "schema": "onec_extension_saved_state_pair_apply.v1", + "status": "invalid_extension_stream", + "applied": False, + "diagnostics": {"message": "__configinfo is the paired index and cannot be edited as the primary stream."}, + } + configinfo_file_name = f"{extension_guid}__configinfo" + conn, config, error = connect_live_sql(base_id, "storage.saved_state.apply_proposal", timeout_seconds=timeout_seconds) + if error: + return error + started = time.time() + try: + cursor = conn.cursor(as_dict=True) + cursor.execute(f"SET LOCK_TIMEOUT {int(timeout_seconds or 30) * 1000}") + cursor.execute( + "SELECT FileName, PartNo, BinaryData FROM dbo.ConfigCASSave WITH (UPDLOCK, HOLDLOCK) " + "WHERE FileName IN (%s, %s) ORDER BY FileName, PartNo", + (module_file_name, configinfo_file_name), + ) + rows_by_name: dict[str, list[dict[str, Any]]] = {module_file_name: [], configinfo_file_name: []} + for row in cursor.fetchall(): + rows_by_name.setdefault(str(row.get("FileName") or ""), []).append(row) + if any(len(rows_by_name.get(name) or []) != 1 for name in (module_file_name, configinfo_file_name)): + conn.rollback() + return { + "schema": "onec_extension_saved_state_pair_apply.v1", + "status": "unsupported_part_layout", + "applied": False, + "source": {"kind": "live_sql", "database": config["database"], "table": "ConfigCASSave", "file_name": module_file_name}, + "diagnostics": {"message": "Paired extension apply requires existing single-part module and __configinfo rows."}, + } + current_module = bytes(rows_by_name[module_file_name][0].get("BinaryData") or b"") + current_configinfo = bytes(rows_by_name[configinfo_file_name][0].get("BinaryData") or b"") + current_module_sha1 = hashlib.sha1(current_module).hexdigest() + if current_module_sha1 != expected_module_sha1: + conn.rollback() + return { + "schema": "onec_extension_saved_state_pair_apply.v1", + "status": "precondition_failed", + "applied": False, + "source": {"kind": "live_sql", "database": config["database"], "table": "ConfigCASSave", "file_name": module_file_name}, + "original": {"expected_module_sha1": expected_module_sha1, "actual_module_sha1": current_module_sha1}, + } + current_configinfo_sha1 = hashlib.sha1(current_configinfo).hexdigest() + if expected_configinfo_sha1 and current_configinfo_sha1 != expected_configinfo_sha1: + conn.rollback() + return { + "schema": "onec_extension_saved_state_pair_apply.v1", + "status": "precondition_failed", + "applied": False, + "source": {"kind": "live_sql", "database": config["database"], "table": "ConfigCASSave", "file_name": module_file_name}, + "configinfo": {"expected_sha1": expected_configinfo_sha1, "actual_sha1": current_configinfo_sha1}, + } + pair = build_extension_saved_state_pair_plan( + logical_file_name=suffix, + current_file_data=current_module, + replacement_file_data=replacement_module_data, + current_configinfo_data=current_configinfo, + ) + if pair.get("status") != "ok": + conn.rollback() + return { + "schema": "onec_extension_saved_state_pair_apply.v1", + "status": pair.get("status"), + "applied": False, + "source": {"kind": "live_sql", "database": config["database"], "table": "ConfigCASSave", "file_name": module_file_name}, + "pair_plan": {key: value for key, value in pair.items() if key != "payload"}, + } + module_backup = write_storage_apply_backup( + base_id=base_id, config=config, table="ConfigCASSave", file_name=module_file_name, + original=current_module, replacement=replacement_module_data, proposal=proposal, + ) + configinfo_backup = write_storage_apply_backup( + base_id=base_id, config=config, table="ConfigCASSave", file_name=configinfo_file_name, + original=current_configinfo, replacement=bytes(pair["configinfo"]["payload"]), + proposal={"kind": "extension_configinfo_pair", "module_file_name": module_file_name, "proposal": proposal}, + ) + cursor.execute( + "UPDATE dbo.ConfigCASSave SET BinaryData=%s, DataSize=%s WHERE FileName=%s AND PartNo=%s", + (replacement_module_data, len(replacement_module_data), module_file_name, rows_by_name[module_file_name][0].get("PartNo")), + ) + cursor.execute( + "UPDATE dbo.ConfigCASSave SET BinaryData=%s, DataSize=%s WHERE FileName=%s AND PartNo=%s", + (pair["configinfo"]["payload"], len(pair["configinfo"]["payload"]), configinfo_file_name, rows_by_name[configinfo_file_name][0].get("PartNo")), + ) + conn.commit() + except Exception as exc: + try: + conn.rollback() + except Exception: + pass + return { + "schema": "onec_extension_saved_state_pair_apply.v1", + "status": "error", + "applied": False, + "source": {"kind": "live_sql", "database": (config or {}).get("database"), "table": "ConfigCASSave", "file_name": module_file_name}, + "diagnostics": {"message": str(exc)}, + } + finally: + try: + conn.close() + except Exception: + pass + module_after, _config, module_error = read_storage_file_bytes(base_id, "ConfigCASSave", module_file_name, timeout_seconds=timeout_seconds) + configinfo_after, _config, configinfo_error = read_storage_file_bytes(base_id, "ConfigCASSave", configinfo_file_name, timeout_seconds=timeout_seconds) + verified = bool( + not module_error and not configinfo_error + and module_after == replacement_module_data + and configinfo_after == pair["configinfo"]["payload"] + ) + return { + "schema": "onec_extension_saved_state_pair_apply.v1", + "status": "applied" if verified else "readback_mismatch", + "applied": verified, + "base_id": base_id, + "source": {"kind": "live_sql", "database": config["database"], "table": "ConfigCASSave", "file_name": module_file_name}, + "paired_file": {"file_name": configinfo_file_name, "service_atoms": "preserved_unmodified"}, + "backups": {"module": module_backup, "configinfo": configinfo_backup}, + "module": {"original_sha1": expected_module_sha1, "replacement_sha1": hashlib.sha1(replacement_module_data).hexdigest()}, + "configinfo": {"original_sha1": pair["configinfo"]["original_sha1"], "replacement_sha1": pair["configinfo"]["replacement_sha1"]}, + "readback": {"verified": verified, "module_error": module_error, "configinfo_error": configinfo_error}, + "configurator_refresh": saved_state_configurator_refresh_guidance( + table="ConfigCASSave", file_name=module_file_name, object_existed_in_saved_state=True, + ), + "cache_invalidation": invalidate_adapter_caches_after_saved_state_change( + base_id, reason="extension_saved_state_pair_apply", source_table="ConfigCASSave", + file_names=[module_file_name, configinfo_file_name], + ), + "duration_ms": int((time.time() - started) * 1000), + } + + +def rollback_extension_saved_state_pair(base_id: str, apply_result: dict[str, Any], *, timeout_seconds: int) -> dict[str, Any]: + backups = apply_result.get("backups") if isinstance(apply_result.get("backups"), dict) else {} + module_backup = backups.get("module") if isinstance(backups.get("module"), dict) else {} + configinfo_backup = backups.get("configinfo") if isinstance(backups.get("configinfo"), dict) else {} + module_path = resolve_storage_apply_backup_path(str(module_backup.get("backup_id") or "") or None, None) + configinfo_path = resolve_storage_apply_backup_path(str(configinfo_backup.get("backup_id") or "") or None, None) + if isinstance(module_path, dict) or isinstance(configinfo_path, dict): + return {"status": "rollback_unavailable", "applied": False, "diagnostics": {"message": "Paired write backups are unavailable."}} + try: + module_evidence = json.loads(module_path.read_text(encoding="utf-8-sig")) + configinfo_evidence = json.loads(configinfo_path.read_text(encoding="utf-8-sig")) + source = module_evidence.get("source") if isinstance(module_evidence.get("source"), dict) else {} + original = module_evidence.get("original") if isinstance(module_evidence.get("original"), dict) else {} + replacement = module_evidence.get("replacement") if isinstance(module_evidence.get("replacement"), dict) else {} + configinfo_replacement = (configinfo_evidence.get("replacement") if isinstance(configinfo_evidence.get("replacement"), dict) else {}) + original_data = bytes.fromhex(str(original.get("payload_hex") or "")) + file_name = str(source.get("file_name") or "") + expected_module_sha1 = str(replacement.get("sha1") or "") + expected_configinfo_sha1 = str(configinfo_replacement.get("sha1") or "") + except (OSError, ValueError, json.JSONDecodeError, UnicodeDecodeError): + return {"status": "rollback_unavailable", "applied": False, "diagnostics": {"message": "Paired write backup evidence is invalid."}} + return apply_extension_saved_state_pair( + base_id, + module_file_name=file_name, + replacement_module_data=original_data, + expected_module_sha1=expected_module_sha1, + expected_configinfo_sha1=expected_configinfo_sha1, + proposal={"method": "metadata.module.write_apply.rollback"}, + timeout_seconds=timeout_seconds, + ) + + def storage_saved_state_apply_proposal(payload: dict[str, Any]) -> dict[str, Any]: method = "storage.saved_state.apply_proposal" base_id_or_error = require_base_id(payload, method) @@ -20003,6 +25521,10 @@ def storage_saved_state_apply_proposal(payload: dict[str, Any]) -> dict[str, Any return invalid_argument(method, "proposal.source.table", "Only saved-state tables may be applied.", allowed_values=sorted(FORM_ELEMENT_SAVED_STATE_TABLES)) if not file_name or Path(file_name).name != file_name: return invalid_argument(method, "proposal.source.file_name", "Proposal source.file_name must be a safe storage FileName.") + extension_guid, extension_separator, _extension_suffix = file_name.partition("__") + extension_pair_required = bool( + table == "ConfigCASSave" and extension_separator and is_guid_text(extension_guid.strip().lower()) + ) for edit in proposal.get("edits") or []: if not isinstance(edit, dict): continue @@ -20061,6 +25583,15 @@ def storage_saved_state_apply_proposal(payload: dict[str, Any]) -> dict[str, Any expected_original_sha1 = str(original.get("sha1") or payload.get("expected_sha1") or "").lower() if not expected_original_sha1: return invalid_argument(method, "proposal.original.sha1", "Proposal must include original.sha1 for the write precondition.") + if extension_pair_required: + return apply_extension_saved_state_pair( + base_id_or_error, + module_file_name=file_name, + replacement_module_data=replacement, + expected_module_sha1=expected_original_sha1, + proposal=proposal, + timeout_seconds=int(timeout_seconds or 30), + ) return apply_storage_file_bytes_single_part( base_id_or_error, table, @@ -20072,16 +25603,34 @@ def storage_saved_state_apply_proposal(payload: dict[str, Any]) -> dict[str, Any ) +WRITE_CONTEXT_KEYS = ( + "lock_session_id", + "repository_object", + "layer_id", + "support_object_guid", + "extension_guid", + "owner_resolution", + "development_layer_resolution", + "module_ref", + "module_id", + "table", + "file_name", + "stream_index", + "expected_sha1", + "expected_text_sha1", +) + + def repository_write_context(payload: dict[str, Any]) -> dict[str, Any]: """Carry resolved repository/support coordination through nested writes.""" return { key: payload[key] - for key in ("lock_session_id", "repository_object", "support_object_guid", "extension_guid", "owner_resolution") + for key in WRITE_CONTEXT_KEYS if payload.get(key) is not None } -REPOSITORY_WRITE_CONTEXT_KEYS = ("lock_session_id", "repository_object", "layer_id") +REPOSITORY_WRITE_CONTEXT_KEYS = WRITE_CONTEXT_KEYS def normalize_repository_write_context(payload: dict[str, Any], method: str) -> tuple[dict[str, Any], dict[str, Any] | None]: @@ -20151,7 +25700,13 @@ def storage_saved_state_rollback(payload: dict[str, Any]) -> dict[str, Any]: "diagnostics": {"message": f"Could not read backup evidence: {exc}"}, } rollback = evidence.get("rollback") if isinstance(evidence.get("rollback"), dict) else {} - rollback_payload = rollback.get("payload") if isinstance(rollback.get("payload"), dict) else None + # v1 backups store a complete request. Accept the short-lived payload + # spelling too, so existing backup evidence remains recoverable. + rollback_payload = ( + rollback.get("request") if isinstance(rollback.get("request"), dict) + else rollback.get("payload") if isinstance(rollback.get("payload"), dict) + else None + ) if not rollback_payload: return { "schema": "onec_storage_saved_state_rollback.v1", @@ -20159,13 +25714,21 @@ def storage_saved_state_rollback(payload: dict[str, Any]) -> dict[str, Any]: "applied": False, "base_id": base_id_or_error, "backup": {"path": str(resolved), "backup_id": evidence.get("backup_id")}, - "diagnostics": {"message": "Backup evidence does not contain rollback.payload."}, + "diagnostics": {"message": "Backup evidence does not contain rollback.request or rollback.payload."}, } rollback_payload = dict(rollback_payload) rollback_payload["base_id"] = base_id_or_error rollback_payload["allow_sql_saved_state_apply"] = True rollback_payload["timeout_seconds"] = int(timeout_seconds or 30) rollback_payload.update(repository_write_context(payload)) + for selector in ("ref", "kind", "name", "guid", "extension", "extension_guid", "layer_id"): + if payload.get(selector) not in {None, ""}: + rollback_payload[selector] = payload[selector] + # This is an internal continuation of a rollback that has already passed + # the public gate. Rebind the proof after loading the backup request, + # whose storage-only fields are intentionally not part of the caller API. + if adapter_write_authorization_is_valid(payload): + rollback_payload["_adapter_write_authorization"] = adapter_write_authorization_proof(rollback_payload) apply_result = storage_saved_state_apply_proposal(rollback_payload) return { "schema": "onec_storage_saved_state_rollback.v1", @@ -20229,6 +25792,144 @@ def storage_saved_state_backups_list(payload: dict[str, Any]) -> dict[str, Any]: } +def storage_saved_state_backups_prune(payload: dict[str, Any]) -> dict[str, Any]: + method = "storage.saved_state.backups.prune" + base_id = str(payload.get("base_id") or "").strip() + table_filter = str(payload.get("table") or "").strip() + file_filter = str(payload.get("file_name") or "").strip() + older_than_days = int(payload.get("older_than_days") or 30) + keep_latest = int(payload.get("keep_latest") if payload.get("keep_latest") is not None else 20) + limit = int(payload.get("limit") or 500) + dry_run = payload.get("dry_run") is not False + confirm_delete = payload.get("confirm_delete") is True + if not dry_run and not confirm_delete: + return { + "schema": "onec_saved_state_backups_prune.v1", + "method": method, + "status": "confirmation_required", + "error": "confirm_delete_required", + "base_id": base_id, + "dry_run": False, + "deleted": False, + } + + root = storage_apply_backup_dir().resolve() + cutoff = time.time() - (older_than_days * 86400) + matching: list[tuple[Path, dict[str, Any], float]] = [] + if root.is_dir(): + for candidate in root.glob("*.json"): + try: + path = candidate.resolve() + path.relative_to(root) + stat = path.stat() + evidence = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError, json.JSONDecodeError, UnicodeDecodeError): + continue + if not isinstance(evidence, dict) or evidence.get("schema") != "onec_storage_apply_backup.v1": + continue + source = evidence.get("source") if isinstance(evidence.get("source"), dict) else {} + if base_id and str(evidence.get("base_id") or "") != base_id: + continue + if table_filter and str(source.get("table") or "") != table_filter: + continue + if file_filter and str(source.get("file_name") or "") != file_filter: + continue + matching.append((path, evidence, float(stat.st_mtime))) + + matching.sort(key=lambda item: item[2], reverse=True) + protected_latest = {path for path, _evidence, _mtime in matching[:keep_latest]} + matching_base_ids = { + str(evidence.get("base_id") or "").strip() + for _path, evidence, _mtime in matching + if str(evidence.get("base_id") or "").strip() + } + history_backup_ids: set[str] = set() + history_unavailable_base_ids: set[str] = set() + for matching_base_id in matching_base_ids: + referenced_ids = metadata_write_history_referenced_backup_ids(matching_base_id) + if referenced_ids is None: + history_unavailable_base_ids.add(matching_base_id) + else: + history_backup_ids.update(referenced_ids) + protected_history = { + path + for path, evidence, _mtime in matching + if str(evidence.get("backup_id") or "") in history_backup_ids + } + protected_history_unavailable = { + path + for path, evidence, _mtime in matching + if not str(evidence.get("base_id") or "").strip() + or str(evidence.get("base_id") or "").strip() in history_unavailable_base_ids + } + protected = protected_latest | protected_history | protected_history_unavailable + selected = [ + item + for item in matching + if item[0] not in protected and item[2] < cutoff + ][:limit] + deleted: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + for path, evidence, modified_at in selected: + item = { + "backup_id": evidence.get("backup_id"), + "created_at_utc": evidence.get("created_at_utc"), + "modified_at": datetime.fromtimestamp(modified_at, timezone.utc).isoformat(), + "path": str(path), + } + if not dry_run: + try: + path.unlink() + item["deleted"] = True + except OSError as exc: + item["deleted"] = False + errors.append( + { + "backup_id": evidence.get("backup_id"), + "error": "backup_delete_failed", + "message": str(exc), + } + ) + else: + item["deleted"] = False + deleted.append(item) + + return { + "schema": "onec_saved_state_backups_prune.v1", + "method": method, + "status": "ok" if not errors else "partial", + "base_id": base_id, + "dry_run": dry_run, + "deleted": bool(not dry_run and any(item.get("deleted") for item in deleted)), + "query": { + "table": table_filter or None, + "file_name": file_filter or None, + "older_than_days": older_than_days, + "keep_latest": keep_latest, + "limit": limit, + }, + "backup_dir": str(root), + "backups": deleted, + "counts": { + "matching": len(matching), + "protected": len(protected), + "protected_latest": len(protected_latest), + "protected_by_write_history": len(protected_history), + "protected_history_unavailable": len(protected_history_unavailable), + "selected": len(selected), + "deleted": sum(1 for item in deleted if item.get("deleted")), + "errors": len(errors), + }, + "errors": errors, + "diagnostics": { + "onec_database_used": False, + "confirmation_required_for_delete": True, + "write_history_backups_protected": True, + "write_history_unavailable_base_ids": sorted(history_unavailable_base_ids), + }, + } + + def metadata_dbnames_summary(payload: dict[str, Any]) -> dict[str, Any]: base_id_or_error = require_base_id(payload, "metadata.dbnames.summary") if isinstance(base_id_or_error, dict): @@ -21347,6 +27048,74 @@ def platform_reference_type_fallback(owner_kind: str | None, field_name: str | N return None +def public_reference_targets(type_info: Any) -> list[dict[str, str]]: + """Return only metadata targets explicitly declared by a public reference type.""" + + if not isinstance(type_info, dict) or type_info.get("kind") != "reference": + return [] + presentation = str(type_info.get("presentation") or "").strip() + for kind, prefix in REF_TYPE_PRESENTATION_PREFIX.items(): + marker = f"{prefix}." + if not presentation.startswith(marker): + continue + name = presentation[len(marker):].strip() + if name: + return [{"kind": kind, "name": name, "ref": f"{kind}.{name}", "presentation": presentation}] + return [] + + +def public_member_type_evidence( + attributes: list[dict[str, Any]], + tabular_sections: list[dict[str, Any]], + dimensions: list[dict[str, Any]], + resources: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Expose declared type evidence without inferring a business relationship.""" + + evidence: list[dict[str, Any]] = [] + + def append_item(item: dict[str, Any], member_kind: str, *, table: str | None = None) -> None: + type_info = item.get("type") + targets = public_reference_targets(type_info) + entry: dict[str, Any] = { + "member": item.get("name"), + "member_kind": member_kind, + "declared_type": type_info, + "evidence": "metadata_declared_type", + **({"table": table} if table else {}), + } + if targets: + entry["status"] = "confirmed_target_type" + entry["targets"] = targets + elif isinstance(type_info, dict) and type_info.get("kind") == "reference": + allowed_types = type_info.get("allowed_types") + entry["status"] = "ambiguous_reference_type" + if isinstance(allowed_types, list) and allowed_types: + entry["allowed_type_categories"] = list(allowed_types) + else: + entry["status"] = "not_reference" + evidence.append(entry) + + for item in attributes: + if isinstance(item, dict): + append_item(item, "attribute") + for item in dimensions: + if isinstance(item, dict): + append_item(item, "dimension") + for item in resources: + if isinstance(item, dict): + append_item(item, "resource") + for section in tabular_sections: + if not isinstance(section, dict): + continue + section_name = str(section.get("name") or "") + append_item(section, "tabular_section") + for column in section.get("columns") or []: + if isinstance(column, dict): + append_item(column, "tabular_section_column", table=section_name or None) + return evidence + + def public_metadata_item( record: dict[str, Any], resolved_types: dict[str, dict[str, Any]], @@ -21355,7 +27124,7 @@ def public_metadata_item( owner_kind: str | None = None, extensions_by_guid: dict[str, dict[str, Any]] | None = None, ) -> dict[str, Any]: - name = record.get("likely_name") + name = best_text_variant(record.get("likely_name")) public_type = public_type_info(record.get("type"), resolved_types, include_storage=include_storage) if not include_storage: fallback_type = platform_reference_type_fallback(owner_kind, str(name or ""), public_type) @@ -21368,7 +27137,7 @@ def public_metadata_item( identity = record.get("identity") if isinstance(record.get("identity"), dict) else {} synonyms = identity.get("synonyms") if isinstance(identity, dict) else None if isinstance(synonyms, dict) and synonyms: - item["synonym"] = next(iter(synonyms.values())) + item["synonym"] = best_text_variant(next(iter(synonyms.values()))) origin = public_origin_from_storage_routes(record.get("storage_routes"), extensions_by_guid) if origin: item["origin"] = origin @@ -21495,19 +27264,37 @@ def live_extensions_from_sql(base_id: str, *, include_storage: bool = False) -> [_UpdateTime], [_ExtensionUsePurpose], [_ExtensionScope], - DATALENGTH([_ExtensionZippedInfo]) AS [_ExtensionZippedInfoBytes] + DATALENGTH([_ExtensionZippedInfo]) AS [_ExtensionZippedInfoBytes], + CONVERT(varchar(2), SUBSTRING([_ExtensionZippedInfo], DATALENGTH([_ExtensionZippedInfo]) - 2, 1), 2) + AS [_ExtensionActivityByteHex] FROM dbo.[_ExtensionsInfo] ORDER BY [_ExtensionOrder], [_ExtName] """ ) for index, row in enumerate(cursor.fetchall(), start=1): idrref = row.get("_IDRRef") + activity_byte_hex = str(row.get("_ExtensionActivityByteHex") or "").strip().upper() + # Controlled live experiments in `upo` toggled three + # independent extensions through Configurator. In every + # observed container, the third byte from the end (not a + # fixed absolute offset) was 82 when the UI checkbox was + # enabled and 81 when it was disabled. Unknown values are + # deliberately not interpreted. + if activity_byte_hex == "82": + active = True + active_inference = "extension_zipped_info_trailing_activity_byte_82" + elif activity_byte_hex == "81": + active = False + active_inference = "extension_zipped_info_trailing_activity_byte_81" + else: + active = None + active_inference = "unresolved_extension_zipped_info_trailing_activity_byte" extension = { "name": jsonable(row.get("_ExtName")), "order": jsonable(row.get("_ExtensionOrder")), "update_time": jsonable(row.get("_UpdateTime")), "guid": dbnames_ext_guid_from_idrref(idrref), - "active": True, + "active": active, } if include_storage: extension.update( @@ -21520,7 +27307,8 @@ def live_extensions_from_sql(base_id: str, *, include_storage: bool = False) -> "dbnames_ext_file": None, "dbnames_ext_file_bytes": None, "extension_zipped_info": {"type": "binary", "bytes": jsonable(row.get("_ExtensionZippedInfoBytes"))}, - "active_inference": "present_in_live_sql_extensions_info", + "activity_byte_hex": activity_byte_hex or None, + "active_inference": active_inference, } ) rows.append(extension) @@ -21550,7 +27338,12 @@ def extension_guid_from_dbnames_source(source: Any) -> str | None: return match.group(1).lower() if match else None -def extension_map_by_guid(base_id: str) -> dict[str, dict[str, Any]]: +def registered_extension_map_by_guid(base_id: str) -> dict[str, dict[str, Any]]: + """Return registered extensions only for internal activity gating. + + This is deliberately not a routing catalogue: an inactive or unresolved + extension is not part of the configuration composition exposed to agents. + """ result = live_extensions_from_sql(base_id, include_storage=False) or {} mapping: dict[str, dict[str, Any]] = {} for extension in result.get("extensions") or []: @@ -21567,6 +27360,15 @@ def extension_map_by_guid(base_id: str) -> dict[str, dict[str, Any]]: return mapping +def extension_map_by_guid(base_id: str) -> dict[str, dict[str, Any]]: + """Return only SQL-proven active extensions for all normal adapter work.""" + return { + guid: extension + for guid, extension in registered_extension_map_by_guid(base_id).items() + if extension.get("active") is True + } + + def public_origin_from_storage_routes(routes: Any, extensions_by_guid: dict[str, dict[str, Any]] | None = None) -> dict[str, Any] | None: if not isinstance(routes, list) or not routes: return None @@ -21632,7 +27434,14 @@ def list_extensions(payload: dict[str, Any] | None = None) -> dict[str, Any]: raw_extensions_result = live_extensions_from_sql(base_id, include_storage=bool(include_storage)) if not raw_extensions_result: return live_source_unavailable("extensions.list", base_id, None) - extensions = list(raw_extensions_result.get("extensions") or []) + # The public configuration composition is only the active extension set. + # Registered but inactive SQL rows are intentionally not exposed as objects + # the adapter can read, search, route, or write. + extensions = [ + extension + for extension in (raw_extensions_result.get("extensions") or []) + if isinstance(extension, dict) and extension.get("active") is True + ] for extension in extensions: if not isinstance(extension, dict): continue @@ -21943,6 +27752,58 @@ def data_fallback_field_routes(base_id: str, physical_names: list[str], *, timeo return result +def data_chart_populated_variant_table( + base_id: str, + table: str, + base_columns: list[dict[str, Any]], + *, + timeout_seconds: int, +) -> tuple[str, list[dict[str, Any]], dict[str, Any] | None]: + """Select the evidenced X1 storage variant of a chart when its base is empty. + + Some 1C installations retain a zero-row ``_Chrc`` table next to the + populated ``_ChrcX1`` variant. This is not a name-based fallback: the + variant is accepted only after SQL proves that it has chart identity/value + columns and the base has no rows. The selected route remains visible to + callers so a diagnostic never looks like an ordinary base-table read. + """ + required = {"_IDRRef", "_Description", "_Type"} + base_names = {str(row.get("name") or "") for row in base_columns} + if not required.issubset(base_names): + return table, base_columns, None + variant = f"{table}X1" + counts, error = data_sql_rows( + base_id, + "SELECT b.name, SUM(p.rows) AS row_count " + "FROM sys.tables b JOIN sys.partitions p ON p.object_id=b.object_id AND p.index_id IN (0,1) " + "WHERE b.name IN (%s, %s) GROUP BY b.name", + (table, variant), + timeout_seconds=timeout_seconds, + ) + if error: + return table, base_columns, None + count_by_name = {str(row.get("name") or ""): int(row.get("row_count") or 0) for row in counts} + if int(count_by_name.get(table) or 0) != 0 or int(count_by_name.get(variant) or 0) <= 0: + return table, base_columns, None + variant_columns, error = data_sql_rows( + base_id, + "SELECT c.name, t.name AS type_name, c.max_length, c.precision, c.scale, c.is_nullable " + "FROM sys.columns c JOIN sys.types t ON t.user_type_id=c.user_type_id " + "JOIN sys.tables b ON b.object_id=c.object_id WHERE b.name=%s ORDER BY c.column_id", + (variant,), + timeout_seconds=timeout_seconds, + ) + if error or not required.issubset({str(row.get("name") or "") for row in variant_columns}): + return table, base_columns, None + return variant, variant_columns, { + "kind": "populated_x1_variant", + "base_table": table, + "base_row_count": int(count_by_name.get(table) or 0), + "variant_row_count": int(count_by_name.get(variant) or 0), + "required_columns": sorted(required), + } + + def data_object_schema_uncached(payload: dict[str, Any]) -> dict[str, Any]: method = "data.schema" base_id_or_error = require_base_id(payload, method) @@ -21953,21 +27814,33 @@ def data_object_schema_uncached(payload: dict[str, Any]) -> dict[str, Any]: return normalized if not has_object_selector(normalized): return invalid_argument(method, "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE) - card_result = get_object( - normalized.get("kind"), - str(normalized.get("name") or normalized.get("guid") or ""), - base_id=base_id_or_error, - include_storage=True, - limit=20, - timeout_seconds=int(normalized.get("timeout_seconds") or 60), - ) - if card_result.get("status") != "ok": - card_result["method"] = method - return card_result - object_card = card_result.get("object") if isinstance(card_result.get("object"), dict) else card_result + requested_kind = canonical_kind(str(normalized.get("kind") or "")) + requested_name = str(normalized.get("name") or normalized.get("guid") or "") + # Fast path for the high-volume chart reader: the identity cache is enough + # to get its DBNames route and avoids the broad effective-metadata scan. + cached_chart = metadata_cache_lookup_row(base_id_or_error, requested_kind, requested_name) if requested_kind == "ChartOfCharacteristicTypes" else None + if cached_chart: + object_card = {key: cached_chart.get(key) for key in ("guid", "kind", "kind_ru", "public_kind", "name", "synonym", "source")} + object_card["status"] = "ok" + object_card["ref"] = f"ChartOfCharacteristicTypes.{object_card.get('name')}" + else: + card_result = get_object( + normalized.get("kind"), requested_name, + base_id=base_id_or_error, include_storage=True, limit=20, + timeout_seconds=int(normalized.get("timeout_seconds") or 60), + ) + if card_result.get("status") != "ok": + card_result["method"] = method + return card_result + object_card = card_result.get("object") if isinstance(card_result.get("object"), dict) else card_result kind = str(object_card.get("kind") or "") storage = object_card.get("storage") if isinstance(object_card.get("storage"), dict) else {} dbname = next((row for row in storage.get("dbnames") or [] if isinstance(row, dict) and row.get("sql_number") is not None), None) + if kind == "ChartOfCharacteristicTypes" and not dbname: + records, _records_error = live_dbnames_records(base_id_or_error, timeout_seconds=min(int(normalized.get("timeout_seconds") or 60), 15)) + dbname_record = next((record for record in records or [] if str(getattr(record, "guid", "") or "").lower() == str(object_card.get("guid") or "").lower() and str(getattr(record, "storage_role", "") or "") == "Chrc"), None) + if dbname_record: + dbname = {"sql_number": int(getattr(dbname_record, "sql_number", 0) or 0)} if not dbname and kind: live_rows = list_objects( kind, @@ -22018,7 +27891,23 @@ def data_object_schema_uncached(payload: dict[str, Any]) -> dict[str, Any]: return error if not rows: return {"schema": "onec_data_schema.v1", "status": "source_missing", "base_id": base_id_or_error, "object": public_metadata_row(object_card), "diagnostics": {"message": "Physical data table was not found."}} - attributes_result = metadata_object_attributes({**normalized, "base_id": base_id_or_error, "include_storage": True, "only": "all"}) + table_variant: dict[str, Any] | None = None + if kind == "ChartOfCharacteristicTypes": + table, rows, table_variant = data_chart_populated_variant_table( + base_id_or_error, + table, + rows, + timeout_seconds=int(normalized.get("timeout_seconds") or 60), + ) + # A chart's record-level Type pattern is decoded from its rows. Calling + # the generic member resolver here is both unhelpful (standard chart + # fields are not ordinary attributes) and can recursively resolve every + # generated type before LIMIT is applied. + attributes_result = ( + {"dimensions": [], "resources": [], "attributes": []} + if kind == "ChartOfCharacteristicTypes" + else metadata_object_attributes({**normalized, "base_id": base_id_or_error, "include_storage": True, "only": "all"}) + ) logical_by_physical: dict[str, dict[str, Any]] = {} for section in ("dimensions", "resources", "attributes"): for item in attributes_result.get(section) or []: @@ -22035,10 +27924,14 @@ def data_object_schema_uncached(payload: dict[str, Any]) -> dict[str, Any]: if not DATA_SYSTEM_COLUMNS.get(physical) and not any(re.match(rf"^{re.escape(candidate)}(?:$|[A-Za-z_])", physical) for candidate in logical_by_physical) ] - fallback_by_physical = data_fallback_field_routes( - base_id_or_error, - unresolved_physical, - timeout_seconds=int(normalized.get("timeout_seconds") or 60), + fallback_by_physical = ( + data_fallback_field_routes( + base_id_or_error, + unresolved_physical, + timeout_seconds=min(int(normalized.get("timeout_seconds") or 60), 20), + ) + if kind != "ChartOfCharacteristicTypes" or truthy(normalized.get("include_chart_field_semantics")) + else {} ) constant_value_type: dict[str, Any] | None = None if kind == "Constant": @@ -22090,15 +27983,28 @@ def data_object_schema_uncached(payload: dict[str, Any]) -> dict[str, Any]: "storage": {key: jsonable(row.get(key)) for key in ("type_name", "max_length", "precision", "scale", "is_nullable")}, } ) - return { + result = { "schema": "onec_data_schema.v1", "status": "ok", "base_id": base_id_or_error, "object": public_metadata_row(object_card), - "table": {"name": table, "row_kind": kind}, + "table": {"name": table, "row_kind": kind, **({"variant": table_variant} if table_variant else {})}, "fields": fields, "counts": {"fields": len(fields), "physical_columns": len(physical_names)}, } + if kind == "ChartOfCharacteristicTypes" and truthy(normalized.get("include_chart_semantics")): + chart_details = metadata_object_special_details( + { + "base_id": base_id_or_error, + "kind": kind, + "name": object_card.get("name"), + "guid": object_card.get("guid"), + "timeout_seconds": int(normalized.get("timeout_seconds") or 60), + } + ) + if chart_details.get("status") in {"ok", "partial"}: + result["chart_semantics"] = chart_details.get("details") + return result def data_schema_cache_key(payload: dict[str, Any]) -> str: @@ -22120,7 +28026,19 @@ def data_schema_cache_key(payload: dict[str, Any]) -> str: identity = {"guid": guid} else: identity = {"selector": "unresolved"} - return json.dumps({"base_id": base_id, **identity}, ensure_ascii=False, sort_keys=True) + return json.dumps( + { + "base_id": base_id, + **identity, + # Semantic chart decoding changes the returned contract and must + # not reuse a compact-schema cache entry that deliberately omitted + # those details. + "include_chart_semantics": truthy(payload.get("include_chart_semantics")), + "include_chart_field_semantics": truthy(payload.get("include_chart_field_semantics")), + }, + ensure_ascii=False, + sort_keys=True, + ) def data_object_schema(payload: dict[str, Any]) -> dict[str, Any]: @@ -22178,6 +28096,148 @@ def data_record_ref(payload: dict[str, Any]) -> str: return legacy if re.fullmatch(r"[0-9a-fA-F]{32}", legacy) else "" +def data_write_version_bytes(value: Any) -> bytes | None: + """Parse the opaque version token returned by ``data.get``. + + It is deliberately not interpreted as a number: different 1C platform + versions use different binary version layouts. It is only used as a + byte-for-byte compare-and-set guard in the SQL WHERE clause. + """ + if isinstance(value, (bytes, bytearray)): + return bytes(value) or None + if isinstance(value, dict): + value = value.get("hex") + if not isinstance(value, str): + return None + compact = value.replace("-", "").replace(" ", "").strip() + if not compact or not re.fullmatch(r"[0-9a-fA-F]+", compact) or len(compact) % 2: + return None + return bytes.fromhex(compact) + + +def data_write_value(value: Any, physical_name: str) -> Any: + """Convert only public scalar/reference values to DB-Library values.""" + if isinstance(value, dict): + if set(value) <= {"hex", "type"} and value.get("hex") is not None: + value = value.get("hex") + else: + raise ValueError("Only scalar values or a reference/binary {hex} value are writable.") + if isinstance(value, (list, tuple)): + raise ValueError("Arrays are not writable through direct SQL.") + return onec_data_filter_value(value, physical_name) + + +def data_write(payload: dict[str, Any]) -> dict[str, Any]: + """Safely update one *existing* catalog row in an isolated SQL data plane. + + This is intentionally narrower than a generic SQL executor. Raw DML + cannot safely create/post 1C objects because it bypasses object modules, + transactions, numbering, register movements and platform locks. The + runtime bridge is the authoritative future route for those operations. + """ + method = "data.write" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + mode = str(payload.get("execution_mode") or "plan").strip().casefold() + if mode not in {"plan", "apply", "apply_and_verify"}: + return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=["plan", "apply", "apply_and_verify"]) + record_ref = data_record_ref(payload) + if not re.fullmatch(r"[0-9a-fA-F]{32}", record_ref): + return invalid_argument(method, "record_ref", "record_ref must be a 32-character hexadecimal 1C reference id.") + values = payload.get("values") + if not isinstance(values, dict) or not values: + return invalid_argument(method, "values", "values must be a non-empty object of logical scalar fields.") + timeout, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=120) + if timeout_error: + return timeout_error + schema = data_object_schema(data_schema_selector_payload(payload)) + if schema.get("status") != "ok": + schema["method"] = method + return schema + obj = schema.get("object") if isinstance(schema.get("object"), dict) else {} + kind = str(obj.get("kind") or "") + table = str((schema.get("table") or {}).get("name") or "") + # The first direct-SQL data capability is deliberately catalog-only. It + # makes the support boundary explicit rather than silently corrupting a + # document/register by bypassing its 1C posting or movement logic. + if kind != "Catalog": + return { + "schema": "onec_data_write.v1", "method": method, "status": "runtime_bridge_required", "base_id": base_id, + "object": obj, + "reason": "Direct SQL updates are currently supported only for existing catalog rows. Documents, registers, constants and creates must use the 1C runtime bridge so platform business logic runs.", + } + if not re.fullmatch(r"_[A-Za-z0-9_]+", table): + return {"schema": "onec_data_write.v1", "method": method, "status": "route_unavailable", "base_id": base_id, "diagnostics": {"message": "Resolved catalog table is unsafe."}} + by_logical: dict[str, list[dict[str, Any]]] = {} + for field in schema.get("fields") or []: + if isinstance(field, dict): + by_logical.setdefault(str(field.get("name") or ""), []).append(field) + forbidden = {"", "ref", "version", "date", "number", "posted", "period", "recorder_ref", "recorder_type", "line_no", "predefined_ref", "is_folder", "parent_ref", "owner_ref"} + assignments: list[tuple[str, str, Any]] = [] + for logical, value in values.items(): + if not isinstance(logical, str) or logical in forbidden: + return invalid_argument(method, "values", f"Field `{logical}` is an identity/platform-managed field and cannot be directly written.") + candidates = by_logical.get(logical) or [] + if len(candidates) != 1: + return invalid_argument(method, "values", f"Field `{logical}` is unknown or composite and cannot be directly written.") + physical = str(candidates[0].get("physical_name") or "") + # Do not permit 1C type discriminators or storage-system columns. + if not re.fullmatch(r"_(?:Fld\d+(?:_[A-Za-z0-9]+)?|Code|Description|Marked)", physical, flags=re.IGNORECASE): + return invalid_argument(method, "values", f"Field `{logical}` has an unsupported physical storage role.") + try: + assignments.append((logical, physical, data_write_value(value, physical))) + except ValueError as exc: + return invalid_argument(method, "values", f"Field `{logical}`: {exc}") + expected_version = data_write_version_bytes(payload.get("expected_version")) + plan = { + "schema": "onec_data_write.v1", "method": method, "status": "planned", "base_id": base_id, "object": obj, + "record_ref": record_ref.upper(), "operation": "update_existing_catalog_row", "values": {name: value for name, _, value in assignments}, + "guards": {"record_ref": "required", "expected_version": "required_for_apply", "allow_sql_data_write": "required_for_apply", "readback": "apply_and_verify"}, + "limitations": ["No INSERT/DELETE/posting/register movement is performed by direct SQL.", "Direct SQL bypasses 1C object-module business logic; use the runtime bridge when that logic is required."], + } + if mode == "plan": + return plan + allowed, allowed_error = strict_bool_argument(payload, "allow_sql_data_write", method=method, default=False) + if allowed_error: + return allowed_error + if not allowed: + return invalid_argument(method, "allow_sql_data_write", "Direct SQL data write is opt-in; review the plan and pass allow_sql_data_write=true.") + if not expected_version: + return invalid_argument(method, "expected_version", "Apply requires the opaque version token returned by data.get.") + conn, config, error = connect_live_sql(base_id, method, timeout_seconds=int(timeout or 30)) + if error: + return error + select_fields = ", ".join(f"[{physical}] AS [{logical}]" for logical, physical, _ in assignments) + set_sql = ", ".join(f"[{physical}]=%s" for _, physical, _ in assignments) + ref_bytes = bytes.fromhex(record_ref) + before: dict[str, Any] | None = None + after: dict[str, Any] | None = None + try: + with conn: + with conn.cursor(as_dict=True) as cursor: + cursor.execute("SET LOCK_TIMEOUT %s", (int(timeout or 30) * 1000,)) + cursor.execute(f"SELECT [ _IDRRef ]".replace("[ _IDRRef ]", "[_IDRRef]") + f", [_Version], {select_fields} FROM dbo.[{table}] WITH (UPDLOCK, ROWLOCK) WHERE [_IDRRef]=%s", (ref_bytes,)) + before = cursor.fetchone() + if not before: + return {**plan, "status": "not_found"} + actual_version = bytes(before.get("_Version") or b"") + if actual_version != expected_version: + return {**plan, "status": "conflict", "error": "expected_version_mismatch", "actual_version": actual_version.hex().upper()} + cursor.execute(f"UPDATE dbo.[{table}] SET {set_sql} WHERE [_IDRRef]=%s AND [_Version]=%s", tuple([*(value for _, _, value in assignments), ref_bytes, expected_version])) + if cursor.rowcount != 1: + return {**plan, "status": "conflict", "error": "compare_and_set_failed"} + cursor.execute(f"SELECT [_Version], {select_fields} FROM dbo.[{table}] WHERE [_IDRRef]=%s", (ref_bytes,)) + after = cursor.fetchone() + except Exception as exc: + return {**plan, "status": "error", "error": "data_sql_write_error", "diagnostics": {"message": str(exc)}} + before_public = decode_data_rows([before or {}], {"_Version": "version", **{logical: logical for logical, _, _ in assignments}})[0] + after_public = decode_data_rows([after or {}], {"_Version": "version", **{logical: logical for logical, _, _ in assignments}})[0] + result = {**plan, "status": "applied", "before": before_public, "after": after_public, "verification": {"status": "verified" if mode == "apply_and_verify" else "written", "rows_affected": 1}} + return result + + def data_schema_selector_payload(payload: dict[str, Any]) -> dict[str, Any]: selector = dict(payload) object_ref = selector.pop("object_ref", None) @@ -22227,7 +28287,358 @@ def enrich_enum_data_rows(rows: list[dict[str, Any]], values: dict[str, dict[str return rows +def additional_attributes_find(payload: dict[str, Any]) -> dict[str, Any]: + """Find a property definition without exposing or guessing storage columns. + + The generic data reader remains the source of rows. This facade exists so + a missing property is distinguishable from an empty/misrouted chart and so + callers do not have to manufacture SQL predicates. + """ + method = "additional_attributes.find" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + chart_ref = str(payload.get("chart_ref") or payload.get("chart") or "ChartOfCharacteristicTypes.ДополнительныеРеквизитыИСведения").strip() + query = str(payload.get("query") or payload.get("description") or payload.get("name") or payload.get("identifier_for_formula") or "").strip() + limit, limit_error = parse_int_argument(payload, "limit", method=method, default=50, minimum=1, maximum=1000) + if limit_error: + return limit_error + read_payload: dict[str, Any] = { + "base_id": base_id_or_error, + "ref": chart_ref, + "limit": int(limit or 50), + # Resolve candidates first. The record Type pattern is deliberately + # excluded here: decoding it for every fuzzy match must not prevent a + # caller from learning whether the property exists at all. + "fields": ["ref", "description", "marked_for_deletion"], + "include_deleted": payload.get("include_deleted") is True, + "include_route_diagnostics": True, + } + if query: + read_payload["search"] = query + result = data_read(read_payload, method=method) + if result.get("status") != "ok": + return result + rows = result.get("rows") if isinstance(result.get("rows"), list) else [] + source = result.get("route_diagnostics") if isinstance(result.get("route_diagnostics"), dict) else {} + owner_object_ref = str(payload.get("owner_object_ref") or payload.get("owner_ref") or "").strip() + # For a narrow owner-scoped result, attach confirmed value-storage evidence + # directly. This keeps an agent from guessing a value type or joining a + # similarly named property from a different property set. + if owner_object_ref and len(rows) <= 5: + for row in rows: + reference = row.get("ref") if isinstance(row, dict) else None + property_ref = str(reference.get("hex") or "") if isinstance(reference, dict) else str(reference or "") + if not re.fullmatch(r"[0-9A-Fa-f]{32}", property_ref): + continue + route = additional_attributes_storage_resolve( + { + "base_id": base_id_or_error, + "property_ref": property_ref, + "owner_object_ref": owner_object_ref, + "timeout_seconds": min(int(payload.get("timeout_seconds") or 30), 20), + } + ) + if route.get("status") == "confirmed": + storage = route.get("storage") if isinstance(route.get("storage"), dict) else {} + row["value_type"] = storage.get("value_type") or {"status": "unresolved"} + row["storage_route"] = { + "status": "confirmed", + "source_ref": storage.get("source_ref"), + "source_kind": storage.get("source_kind"), + "fields": storage.get("fields"), + "scd_join": route.get("scd_join"), + } + else: + row["value_type"] = {"status": "unresolved"} + row["storage_route"] = {"status": route.get("status") or "unresolved", "diagnostics": route.get("diagnostics")} + status = "found" if rows else "not_found" + if not rows and source.get("status") == "empty_source": + status = "empty_source" + return { + "schema": "onec_additional_attributes_find.v1", + "status": status, + "base_id": base_id_or_error, + "chart": result.get("object"), + "chart_semantics": result.get("chart_semantics"), + "query": {"text": query or None, "limit": int(limit or 50)}, + "properties": rows, + "counts": result.get("counts"), + "diagnostics": { + "message": ( + "The chart data source contains no records in this base; a property cannot be confirmed." + if status == "empty_source" + else "No property matched the supplied public search text." + if status == "not_found" + else "Property definitions were found through the public chart route." + ), + "route": source, + "semantic_fields": ["ref", "description", "marked_for_deletion", "value_type", "storage_route"], + "unresolved_fields": ["name", "identifier_for_formula", "property_set"], + }, + } + + +def enrich_chart_value_types( + base_id: str, + rows: list[dict[str, Any]], + raw_rows: list[dict[str, Any]], + *, + timeout_seconds: int, +) -> list[dict[str, Any]]: + """Decode each characteristic's stored Type pattern into a public type. + + ``_Type`` is a platform pattern blob, not an SQL type. Its parser is the + same SQL payload decoder used for configuration metadata; this deliberately + avoids a field-number profile or a runtime 1C call. + """ + for item, raw_row in zip(rows, raw_rows): + raw_type = raw_row.get("_Type") + if not isinstance(raw_type, (bytes, bytearray)): + continue + cache_key = (base_id.casefold(), hashlib.sha1(bytes(raw_type)).hexdigest()) + with CHART_VALUE_TYPE_CACHE_LOCK: + cached = CHART_VALUE_TYPE_CACHE.get(cache_key) + value_type = copy.deepcopy(cached.get("value_type")) if cached and time.time() - float(cached.get("cached_at") or 0) <= CHART_VALUE_TYPE_CACHE_TTL_SECONDS else None + if value_type is None: + tree = parse_config_tree_from_bytes(bytes(raw_type)) + if tree is None: + continue + try: + value_type = public_pattern_value_type(base_id, tree, table="Config", timeout_seconds=min(timeout_seconds, 15)) + except Exception: + continue + if isinstance(value_type, dict) and value_type.get("kind"): + with CHART_VALUE_TYPE_CACHE_LOCK: + CHART_VALUE_TYPE_CACHE[cache_key] = {"cached_at": time.time(), "value_type": copy.deepcopy(value_type)} + if isinstance(value_type, dict) and value_type.get("kind"): + item["value_type"] = value_type + return rows + + +def additional_attributes_storage_resolve(payload: dict[str, Any]) -> dict[str, Any]: + """Resolve only evidenced value-storage routes for an additional property. + + A generic 1C configuration can use a different register than BSP. Until + the property and Object/Property/Value roles are proven by metadata, return + ``unresolved`` rather than a plausible-looking SQL join. + """ + method = "additional_attributes.storage.resolve" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + raw_property_ref = payload.get("property_ref") or payload.get("ref") or "" + if isinstance(raw_property_ref, dict): + property_ref = str(raw_property_ref.get("hex") or raw_property_ref.get("ref") or "").replace("-", "").strip() + else: + property_ref = str(raw_property_ref).replace("-", "").strip() + owner_ref = str(payload.get("owner_object_ref") or payload.get("owner_ref") or payload.get("owner") or "").strip() + if not re.fullmatch(r"[0-9a-fA-F]{32}", property_ref): + return invalid_argument(method, "property_ref", "property_ref must be the 32-character reference returned by additional_attributes.find.") + if not owner_ref: + return invalid_argument(method, "owner_ref", "owner_ref is required and must be a public owner selector, for example Catalog.СтруктурныеЕдиницы.") + + # BSP-style additional requisites are commonly a tabular section of the + # owner, rather than an information register. Resolve this from the owner + # metadata and DBNames routes: no field number is supplied by the caller. + owner_attributes = metadata_object_attributes({"base_id": base_id_or_error, "ref": owner_ref, "only": "all", "include_storage": True}) + owner_card = owner_attributes.get("object") if isinstance(owner_attributes.get("object"), dict) else {} + if owner_attributes.get("status") == "ok": + for section in owner_attributes.get("tabular_sections") or []: + if not isinstance(section, dict): + continue + columns = section.get("columns") if isinstance(section.get("columns"), list) else [] + by_name = {normalize(str(column.get("name") or "")): column for column in columns if isinstance(column, dict)} + property_column = by_name.get(normalize("Свойство")) + value_column = by_name.get(normalize("Значение")) + text_column = by_name.get(normalize("ТекстоваяСтрока")) + section_route = next((route for route in section.get("storage_routes") or [] if isinstance(route, dict) and route.get("storage_role") == "VT"), None) + property_route = next((route for route in (property_column or {}).get("storage_routes") or [] if isinstance(route, dict) and route.get("storage_role") == "Fld"), None) + value_route = next((route for route in (value_column or {}).get("storage_routes") or [] if isinstance(route, dict) and route.get("storage_role") == "Fld"), None) + if not (property_column and value_column and section_route and property_route and value_route): + continue + section_number = int(section_route.get("sql_number") or 0) + property_number = int(property_route.get("sql_number") or 0) + value_number = int(value_route.get("sql_number") or 0) + if not (section_number and property_number and value_number): + continue + owner_guid = str(owner_card.get("guid") or "").lower() + owner_kind = str(owner_card.get("kind") or "") + owner_role = str(DATA_TABLE_PREFIXES.get(owner_kind) or "").lstrip("_") + records, records_error = live_dbnames_records(base_id_or_error) + owner_record = next( + (record for record in records or [] if str(getattr(record, "guid", "") or "").lower() == owner_guid and str(getattr(record, "storage_role", "") or "") == owner_role), + None, + ) if not records_error else None + owner_number = int(getattr(owner_record, "sql_number", 0) or 0) + if not owner_role or not owner_number: + continue + table_base = f"_{owner_role}{owner_number}_VT{section_number}" + property_prefix, value_prefix = f"_Fld{property_number}", f"_Fld{value_number}" + proven_rows: list[dict[str, Any]] = [] + selected_table = "" + selected_owner_physical = "" + selected_property_physical = "" + selected_value_physical = "" + for table_name in (table_base, f"{table_base}X1"): + columns_rows, columns_error = data_sql_rows( + base_id_or_error, + "SELECT c.name FROM sys.columns c JOIN sys.tables t ON t.object_id=c.object_id WHERE t.name=%s ORDER BY c.column_id", + (table_name,), + ) + if columns_error: + continue + physical_columns = {str(row.get("name") or "") for row in columns_rows} + owner_physical = f"_{owner_role}{owner_number}_IDRRef" + property_physical = next((name for name in physical_columns if re.match(rf"^{re.escape(property_prefix)}(?:$|[A-Za-z_])", name)), "") + value_physical = next((name for name in physical_columns if re.match(rf"^{re.escape(value_prefix)}(?:$|[A-Za-z_])", name)), "") + if owner_physical not in physical_columns or not property_physical or not value_physical: + continue + candidate_rows, proven_error = data_sql_rows( + base_id_or_error, + f"SELECT COUNT_BIG(*) AS row_count FROM dbo.[{table_name}] WHERE [{property_physical}]=%s", + (bytes.fromhex(property_ref),), + ) + if proven_error or not candidate_rows or int((candidate_rows[0] or {}).get("row_count") or 0) <= 0: + continue + selected_table, selected_owner_physical = table_name, owner_physical + selected_property_physical, selected_value_physical = property_physical, value_physical + proven_rows = candidate_rows + break + if not selected_table: + continue + owner_name = str(owner_card.get("name") or "").strip() + source_ref = f"Справочник.{owner_name}.{section.get('name')}" if owner_name else f"{owner_ref}.{section.get('name')}" + alias = "ДополнительныеРеквизиты" + result = { + "schema": "onec_additional_attributes_storage_route.v1", + "status": "confirmed", + "base_id": base_id_or_error, + "property_ref": property_ref.upper(), + "owner_ref": owner_ref, + "storage": { + "source_ref": source_ref, + "source_kind": "TabularSection", + "fields": {"object": "Ссылка", "property": property_column.get("name"), "value": value_column.get("name"), **({"text": text_column.get("name")} if text_column else {})}, + "value_type": value_column.get("type"), + }, + "scd_join": { + "source": source_ref, + "alias": alias, + "condition": f"{alias}.Ссылка = СтруктурнаяЕдиница.Ссылка И {alias}.Свойство = &СвойствоОтветственноеНаправление", + "value_expression": f"{alias}.Значение", + "parameters": {"СвойствоОтветственноеНаправление": property_ref.upper()}, + }, + "evidence": { + "owner_tabular_section": section.get("name"), + "matched_roles": ["Ссылка", "Свойство", "Значение"], + "property_row_count": int((proven_rows[0] or {}).get("row_count") or 0), + "metadata_routes": {"tabular_section": section_route, "property": property_route, "value": value_route}, + "sql_route": {"table": selected_table, "owner": selected_owner_physical, "property": selected_property_physical, "value": selected_value_physical}, + }, + } + return result + requested_register = str(payload.get("register_ref") or "").strip() + if requested_register: + candidate_refs = [requested_register] + else: + listed = list_objects( + "InformationRegister", + base_id=base_id_or_error, + limit=100, + offset=0, + include_storage=False, + name_filter="ДополнительныеСведения", + ) + candidate_refs = [str(item.get("ref") or "") for item in listed.get("objects") or [] if isinstance(item, dict) and item.get("ref")] + evidence: list[dict[str, Any]] = [] + for register_ref in candidate_refs: + attributes = metadata_object_attributes({"base_id": base_id_or_error, "ref": register_ref, "only": "all"}) + if attributes.get("status") != "ok": + continue + dimensions = attributes.get("dimensions") if isinstance(attributes.get("dimensions"), list) else [] + resources = attributes.get("resources") if isinstance(attributes.get("resources"), list) else [] + by_name = {normalize(str(row.get("name") or "")): row for row in dimensions if isinstance(row, dict)} + object_field = by_name.get(normalize("Объект")) + property_field = by_name.get(normalize("Свойство")) + value_field = next((row for row in resources if isinstance(row, dict) and normalize(str(row.get("name") or "")) == normalize("Значение")), None) + resolved_type = ((property_field or {}).get("type") or {}).get("resolved") if isinstance((property_field or {}).get("type"), dict) else {} + if not isinstance(resolved_type, dict): + resolved_type = {} + property_type = (property_field or {}).get("type") if isinstance((property_field or {}).get("type"), dict) else {} + chart_match = ( + str(resolved_type.get("kind") or "") == "ChartOfCharacteristicTypes" + and str(resolved_type.get("name") or "") == "ДополнительныеРеквизитыИСведения" + ) or str((property_type or {}).get("presentation") or "") == "ПланВидовХарактеристикСсылка.ДополнительныеРеквизитыИСведения" + if object_field and property_field and value_field and chart_match: + return { + "schema": "onec_additional_attributes_storage_route.v1", + "status": "confirmed", + "base_id": base_id_or_error, + "property_ref": property_ref.upper(), + "owner_ref": owner_ref, + "storage": { + "source_ref": register_ref, + "source_kind": "InformationRegister", + "fields": {"object": object_field.get("name"), "property": property_field.get("name"), "value": value_field.get("name")}, + "value_type": value_field.get("type"), + }, + "scd_join": { + "source": register_ref, + "alias": "ДополнительныеСведения", + "condition": "ДополнительныеСведения.Объект = СтруктурнаяЕдиница.Ссылка И ДополнительныеСведения.Свойство = &Свойство", + "value_expression": "ДополнительныеСведения.Значение", + "parameters": {"Свойство": property_ref.upper()}, + }, + "evidence": { + "property_dimension_type": resolved_type, + "matched_roles": ["Объект", "Свойство", "Значение"], + }, + } + evidence.append({"source_ref": register_ref, "object": bool(object_field), "property": bool(property_field), "value": bool(value_field), "property_chart_match": chart_match}) + return { + "schema": "onec_additional_attributes_storage_route.v1", + "status": "unresolved", + "base_id": base_id_or_error, + "property_ref": property_ref.upper(), + "owner_ref": owner_ref, + "storage": None, + "diagnostics": { + "message": "No confirmed Object/Property/Value route is available yet. The adapter will not infer a register or _Fld columns from names.", + "required_evidence": ["confirmed_property_definition", "register_object_dimension", "register_property_dimension", "register_value_resource"], + "candidates_checked": evidence, + }, + } + + def data_read(payload: dict[str, Any], *, count_only: bool = False, method: str | None = None) -> dict[str, Any]: + """Read data under one wall-clock budget, including metadata decoding. + + SQL command timeouts alone cannot bound resolver/decode work performed after + a query returns. The outer guard therefore applies to the complete public + call. The worker is allowed to finish in the background so we never abort + a DB-Library call unsafely, while the MCP caller receives a final result. + """ + method = method or ("data.count" if count_only else "data.list") + if not payload.get("_data_read_deadline_guard"): + timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=120) + if timeout_error: + return timeout_error + guarded_payload = dict(payload) + guarded_payload["_data_read_deadline_guard"] = True + executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + future = executor.submit(data_read, guarded_payload, count_only=count_only, method=method) + started = time.monotonic() + try: + return future.result(timeout=int(timeout_seconds or 30)) + except concurrent.futures.TimeoutError: + return { + "schema": "onec_data_error.v1", "method": method, "status": "error", "error": "query_timeout", + "base_id": payload.get("base_id"), + "diagnostics": {"stage": "additional_attributes.find.chart_read" if method == "additional_attributes.find" else "data.read", "elapsed_seconds": int(timeout_seconds or 30), "object_ref": payload.get("object_ref") or payload.get("ref"), "query_route": "sql_metadata_and_chart_read"}, + } + finally: + executor.shutdown(wait=False, cancel_futures=False) method = method or ("data.count" if count_only else "data.list") schema = data_object_schema(data_schema_selector_payload(payload)) if schema.get("status") != "ok": @@ -22239,9 +28650,22 @@ def data_read(payload: dict[str, Any], *, count_only: bool = False, method: str by_logical: dict[str, list[dict[str, Any]]] = {} for field in all_fields: by_logical.setdefault(str(field.get("name") or ""), []).append(field) + kind = str((schema.get("object") or {}).get("kind") or "") requested_fields = payload.get("fields") if requested_fields is None: - requested_names = list(by_logical) + # Chart type patterns are binary metadata payloads, not ordinary list + # columns. Selecting every physical field made a harmless + # ``data.list(..., limit=5)`` decode several Type patterns and could + # consume the whole request timeout. List calls therefore default to + # the stable public identity fields; callers that truly need types use + # the dedicated property finder/explicit fields path. + if kind == "ChartOfCharacteristicTypes": + requested_names = [ + name for name in ("ref", "description", "name", "marked_for_deletion") + if name in by_logical + ] + else: + requested_names = list(by_logical) elif isinstance(requested_fields, list) and all(isinstance(item, str) for item in requested_fields): requested_names = list(dict.fromkeys(requested_fields)) else: @@ -22268,8 +28692,30 @@ def data_read(payload: dict[str, Any], *, count_only: bool = False, method: str physical = str(candidates[0].get("physical_name") or "") if not re.fullmatch(r"_[A-Za-z0-9_]+", physical): return invalid_argument(method, "filters", f"Unsafe physical route for `{name}`.") + if isinstance(value, dict): + operator = str(value.get("operator") or "").strip().casefold() + operand = value.get("value") + if operator != "contains" or kind != "ChartOfCharacteristicTypes" or str(name) != "description": + return invalid_argument(method, "filters", f"Operator `{operator or 'missing'}` is unsupported for `{name}`.") + if not isinstance(operand, str): + return invalid_argument(method, "filters", "contains requires a string value.") + where.append(f"[{physical}] LIKE %s") + params.append(f"%{operand}%") + continue where.append(f"[{physical}]=%s") params.append(onec_data_filter_value(value, physical)) + search = payload.get("search") + if search not in {None, ""}: + if not isinstance(search, str): + return invalid_argument(method, "search", "search must be a JSON string.") + if kind != "ChartOfCharacteristicTypes": + return invalid_argument(method, "search", "search is currently available for ChartOfCharacteristicTypes only.") + description_fields = by_logical.get("description") or [] + description_physical = str((description_fields[0] or {}).get("physical_name") or "") if len(description_fields) == 1 else "" + if not re.fullmatch(r"_[A-Za-z0-9_]+", description_physical): + return {"schema": "onec_data_error.v1", "status": "route_unavailable", "base_id": base_id, "diagnostics": {"message": "The chart has no safe semantic description route for search."}} + where.append(f"[{description_physical}] LIKE %s") + params.append(f"%{search}%") if not payload.get("include_deleted") and "marked_for_deletion" in by_logical: where.append("[_Marked]=0x00") where_sql = " WHERE " + " AND ".join(where) if where else "" @@ -22311,6 +28757,8 @@ def data_read(payload: dict[str, Any], *, count_only: bool = False, method: str if error: return error decoded = decode_data_rows(rows, alias_map, alias_types) + if kind == "ChartOfCharacteristicTypes" and truthy(payload.get("include_value_types")): + decoded = enrich_chart_value_types(base_id, decoded, rows, timeout_seconds=timeout) if str((schema.get("object") or {}).get("kind") or "") == "Enum": decoded = enrich_enum_data_rows( decoded, @@ -22320,15 +28768,34 @@ def data_read(payload: dict[str, Any], *, count_only: bool = False, method: str timeout_seconds=timeout, ), ) - return { + result = { "schema": "onec_data_result.v1", "status": "ok", "base_id": base_id, "object": schema.get("object"), "rows": decoded, "counts": {"rows": len(decoded), "limit": int(limit or 100), "offset": int(offset or 0)}, - "query": {"fields": requested_names, "filters": filters, "order_by": order_field, "order": direction}, + "query": {"fields": requested_names, "filters": filters, "search": search or None, "order_by": order_field, "order": direction}, } + if kind == "ChartOfCharacteristicTypes" and schema.get("chart_semantics"): + result["chart_semantics"] = schema.get("chart_semantics") + if kind == "ChartOfCharacteristicTypes" and truthy(payload.get("include_route_diagnostics")): + diagnostic_rows, diagnostic_error = data_sql_rows( + base_id, + f"SELECT COUNT_BIG(*) AS row_count FROM dbo.[{table}]", + timeout_seconds=timeout, + ) + if diagnostic_error: + result["route_diagnostics"] = {"status": "diagnostic_unavailable", "table": {"row_kind": kind}, "message": (diagnostic_error.get("diagnostics") or {}).get("message")} + else: + source_count = int((diagnostic_rows[0] or {}).get("row_count") or 0) + result["route_diagnostics"] = { + "status": "empty_source" if source_count == 0 else "ok", + "table": {"row_kind": kind}, + "source_count": source_count, + "message": "The resolved chart table has no rows." if source_count == 0 else "The resolved chart table contains rows.", + } + return result def decode_data_rows( @@ -22625,7 +29092,7 @@ MODULE_READ_MODES = ["text", "summary", "routines", "routines_only"] def validate_modules_read_arguments(payload: dict[str, Any]) -> dict[str, Any] | None: - for name in ["include_storage", "include_text", "summary", "routines_only", "include_container_preview"]: + for name in ["include_storage", "include_text", "summary", "routines_only", "include_routines", "include_summary", "include_container_preview", "resolve_owner"]: _, bool_error = strict_bool_argument(payload, name, method="modules.read", default=False) if bool_error: return bool_error @@ -22640,6 +29107,8 @@ def validate_modules_read_arguments(payload: dict[str, Any]) -> dict[str, Any] | return preview_error if "routine_name" in payload and payload.get("routine_name") is not None and not isinstance(payload.get("routine_name"), str): return invalid_argument("modules.read", "routine_name", "routine_name must be a JSON string.") + if payload.get("routine_name") and (payload.get("line_start") is not None or payload.get("line_end") is not None): + return invalid_argument("modules.read", "routine_name/line_range", "Pass routine_name or line_start/line_end, not both.") string_error = validate_optional_non_empty_string_arguments(payload, "modules.read", ["module_id", "module_ref"]) if string_error: return string_error @@ -22649,6 +29118,14 @@ def validate_modules_read_arguments(payload: dict[str, Any]) -> dict[str, Any] | _, bsl_offset_error = parse_int_argument(payload, "bsl_offset", method="modules.read", default=0, minimum=0) if bsl_offset_error: return bsl_offset_error + line_start, line_start_error = parse_int_argument(payload, "line_start", method="modules.read", default=1, minimum=1) + if line_start_error: + return line_start_error + line_end, line_end_error = parse_int_argument(payload, "line_end", method="modules.read", default=line_start, minimum=1) + if line_end_error: + return line_end_error + if (payload.get("line_start") is not None or payload.get("line_end") is not None) and int(line_end or 1) < int(line_start or 1): + return invalid_argument("modules.read", "line_end", "line_end must be greater than or equal to line_start.") _, offset_error = parse_int_argument(payload, "offset", method="modules.read", default=0, minimum=0) if offset_error: return offset_error @@ -22695,9 +29172,20 @@ def module_text_response( for routine in routines ] routine_name = str(payload.get("routine_name") or "").strip() + has_line_range = payload.get("line_start") is not None or payload.get("line_end") is not None selected_text = normalized selected_range: dict[str, Any] | None = None - if routine_name: + if has_line_range: + line_start = max(1, int(payload.get("line_start") or 1)) + line_end = max(line_start, int(payload.get("line_end") or line_start)) + if line_start > len(lines): + selected_text = "" + selected_range = {"line_start": line_start, "line_end": line_end, "status": "not_found"} + else: + line_end = min(line_end, len(lines)) + selected_text = "\n".join(lines[line_start - 1 : line_end]) + selected_range = {"line_start": line_start, "line_end": line_end, "match_by": "line_range"} + elif routine_name: wanted = normalize(routine_name) routine = next((item for item in routines if normalize(str(item.get("name") or "")) == wanted), None) if routine: @@ -22723,16 +29211,28 @@ def module_text_response( summary_requested = mode in {"summary", "routines", "routines_only"} or truthy(payload.get("summary")) or truthy(payload.get("routines_only")) or not include_text if mode in {"summary", "routines", "routines_only"}: include_text = False - result: dict[str, Any] = { - "summary": { + # A routine read is normally a focused operation. Returning every method + # of a large common module along with one selected procedure wastes most of + # the MCP context window, so the concise view is the default only when a + # routine was explicitly selected. Full inventories remain opt-in and + # preserve the historic default for whole-module reads. + include_routines = truthy(payload.get("include_routines", "0" if routine_name else "1")) + include_summary = truthy(payload.get("include_summary", "0" if routine_name else "1")) + if mode in {"routines", "routines_only"} or truthy(payload.get("routines_only")): + include_routines = True + if mode == "routines_only": + include_summary = False + result: dict[str, Any] = {} + if include_summary: + result["summary"] = { "chars": len(normalized), "lines": len(lines), "routines": len(public_routines), "procedures": sum(1 for item in public_routines if str(item.get("kind") or "").casefold() == "процедура"), "functions": sum(1 for item in public_routines if str(item.get("kind") or "").casefold() == "функция"), - }, - "routines": public_routines, - } + } + if include_routines: + result["routines"] = public_routines if selected_range: result["selection"] = selected_range if offset_error or max_chars_error: @@ -22746,8 +29246,8 @@ def module_text_response( if selected_range and selected_range.get("status") == "not_found": result["status"] = "not_found" result["method"] = "modules.read" - result["error"] = "routine_not_found" - result["diagnostics"] = {"message": f"Процедура или функция `{routine_name}` не найдена в модуле."} + result["error"] = "routine_not_found" if routine_name else "line_range_not_found" + result["diagnostics"] = {"message": f"Процедура или функция `{routine_name}` не найдена в модуле."} if routine_name else {"message": "Запрошенный диапазон строк находится за пределами модуля."} elif int(offset or 0) > len(selected_text): result["status"] = "range_not_satisfiable" result["error"] = "offset_out_of_range" @@ -22764,6 +29264,11 @@ def module_text_response( } if preview_requested and include_text and not summary_requested: result["preview"] = fragment + # Hashes are public, stable read preconditions; they do not expose physical + # storage coordinates. A write must still supply an exact logical path and + # origin, but an agent can now carry the text guard forward from code.read. + result["content_sha1"] = hashlib.sha1(normalized.encode("utf-8")).hexdigest() + result["selection_sha1"] = hashlib.sha1(selected_text.encode("utf-8")).hexdigest() return result @@ -22778,7 +29283,8 @@ def cached_module_owner_payload(base_id: str, module_id: str) -> dict[str, Any] module_payload = cached.get("module_payload") if isinstance(cached.get("module_payload"), dict) else {} if not owner_payload: return None - return { + module_extension = module_payload.get("extension") if isinstance(module_payload.get("extension"), dict) else {} + result = { "owner": { "status": "resolved" if owner_payload.get("guid") else "partial", "kind": owner_payload.get("kind"), @@ -22789,6 +29295,17 @@ def cached_module_owner_payload(base_id: str, module_id: str) -> dict[str, Any] }, "module": module_payload, } + if module_extension.get("guid") or module_extension.get("name"): + result["origin"] = { + "source": "extension", + "presentation": "Расширение", + "extension": { + "guid": module_extension.get("guid"), + "name": module_extension.get("name"), + }, + "status": "ok" if module_extension.get("name") else "extension_unresolved", + } + return result def module_ref_matches(candidate: str, wanted: str) -> bool: @@ -22946,7 +29463,7 @@ def merge_module_owner_context(result: dict[str, Any], owner_context: dict[str, origin = owner_context.get("origin") if isinstance(owner_context.get("origin"), dict) else None if owner and not result.get("owner"): result["owner"] = owner - if origin and not result.get("origin"): + if origin and (not result.get("origin") or str((result.get("origin") or {}).get("status") or "") == "owner_unresolved"): result["origin"] = origin form_from_context = owner_context.get("form") if isinstance(owner_context.get("form"), dict) else None if form_from_context and not result.get("form"): @@ -22988,6 +29505,17 @@ def read_module(payload: dict[str, Any]) -> dict[str, Any]: if isinstance(table_for_read, dict): return table_for_read include_storage = bool(payload.get("include_storage", False)) + resolve_owner, resolve_owner_error = strict_bool_argument(payload, "resolve_owner", method="modules.read", default=True) + if resolve_owner_error: + return resolve_owner_error + # `state=save` means an explicit saved-state read. `working` is an + # effective view: resolve a public selector from active metadata first, + # then let code.read choose a saved counterpart only if it exists. + # Otherwise a first-write extension object is incorrectly forced to an + # absent ConfigCASSave row and cannot be read or planned. + requested_state = str(payload.get("state") or "").strip().casefold() + if "table" not in payload and requested_state in {"save", "saved"}: + table_for_read = "ConfigCASSave" if str(payload.get("extension") or "").strip() else "ConfigSave" module_id = str(payload.get("module_id") or payload.get("module_ref") or "") selected_module: dict[str, Any] | None = None owner_context: dict[str, Any] | None = None @@ -22996,7 +29524,8 @@ def read_module(payload: dict[str, Any]) -> dict[str, Any]: module_ordinal, ordinal_error = parse_ordinal(module_ordinal_value, "modules.read", argument="module_ordinal") if ordinal_error: return ordinal_error - modules_result = metadata_object_modules({**payload, "include_storage": True, "table": table_for_read}) + modules_payload = {key: value for key, value in payload.items() if key not in {"module", "module_name"}} + modules_result = metadata_object_modules({**modules_payload, "include_storage": True, "table": table_for_read}) if modules_result.get("status") != "ok": return public_error_result(modules_result, include_storage=include_storage, method="modules.read") object_info = modules_result.get("object") if isinstance(modules_result.get("object"), dict) else {} @@ -23011,9 +29540,38 @@ def read_module(payload: dict[str, Any]) -> dict[str, Any]: }, "module": {"module_ordinal": module_ordinal}, } + object_origin = object_info.get("origin") if isinstance(object_info.get("origin"), dict) else None + if object_origin: + owner_context["origin"] = object_origin modules = [module for module in modules_result.get("modules") or [] if isinstance(module, dict)] - index = int(module_ordinal or 1) - 1 - if index < 0 or index >= len(modules): + requested_module_role = str(payload.get("module") or "").strip().casefold() + if requested_module_role: + role_aliases = { + "object_module": {"object_module", "модуль объекта"}, + "command_module": {"command_module", "модуль команды"}, + "form_module": {"form_module", "модуль формы"}, + } + wanted_roles = next((aliases for key, aliases in role_aliases.items() if requested_module_role in aliases), {requested_module_role}) + role_matches = [ + module for module in modules + if str(module.get("kind") or "").casefold() in wanted_roles + or str(module.get("name") or "").strip().casefold() in wanted_roles + ] + # Platform payloads can expose a short service stream beside the + # actual object module. Prefer the substantial BSL stream; this is + # deterministic and still never crosses an object boundary. + if role_matches: + selected_module = max( + role_matches, + key=lambda item: (int(item.get("bytes") or 0), int(item.get("stream_index") or -1)), + ) + module_ordinal = int(selected_module.get("module_ordinal") or module_ordinal or 1) + else: + selected_module = None + else: + index = int(module_ordinal or 1) - 1 + selected_module = modules[index] if 0 <= index < len(modules) else None + if selected_module is None: return { "schema": "onec_module_read.v1", "method": "modules.read", @@ -23030,17 +29588,62 @@ def read_module(payload: dict[str, Any]) -> dict[str, Any]: }, "diagnostics": {"message": f"Module ordinal {module_ordinal} was not found for the selected object."}, } - selected_module = modules[index] module_id = str(selected_module.get("module_id") or "") + active_table, active_file_name, active_stream_index = parse_module_id(module_id) + if table_for_read in FORM_ELEMENT_SAVED_STATE_TABLES and active_table in SAVED_STATE_TARGET_BY_SOURCE and active_file_name: + module_id = f"{SAVED_STATE_TARGET_BY_SOURCE[active_table]}:{active_file_name}" + if active_stream_index is not None: + module_id += f"#stream:{active_stream_index}" table, file_name, stream_index = parse_module_id(module_id) if not table or not file_name: + diagnostics: dict[str, Any] = {"message": MODULE_READ_SELECTOR_OR_MODULE_ID_MESSAGE} + # A bare identifier is often a common-module name accidentally passed + # as an opaque module_ref. Resolve that public name only for an error + # hint; never silently reinterpret module_ref and risk selecting a + # different owner type. + if ":" not in module_id and re.fullmatch(r"[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]*", module_id): + named_module = get_object( + "CommonModule", + module_id, + base_id=base_id_or_error, + include_semantic=False, + timeout_seconds=min(int(payload.get("timeout_seconds") or 30), 15), + ) + named_object = named_module.get("object") if isinstance(named_module.get("object"), dict) else {} + if named_module.get("status") == "ok" and named_object.get("name"): + public_ref = named_object.get("ref") or object_selector_ref("CommonModule", named_object.get("name")) + diagnostics.update( + { + "message": "module_ref is an opaque handle returned by modules.search/code.search, not a public module name.", + "detected_public_module": {"kind": "CommonModule", "name": named_object.get("name"), "ref": public_ref}, + "next_call": { + "method": "modules.read", + "params": { + "base_id": base_id_or_error, + "ref": public_ref, + "module_ordinal": 1, + }, + }, + } + ) return { "schema": "onec_adapter_request_error.v1", "method": "modules.read", "status": "error", "error": "invalid_module_id", - "diagnostics": {"message": MODULE_READ_SELECTOR_OR_MODULE_ID_MESSAGE}, + "diagnostics": diagnostics, } + # An extension saved-state stream with a GUID prefix has a proven layer + # identity before decoding. Reject it early only when that exact extension + # is inactive; opaque non-prefixed module refs still require owner proof. + if table == "ConfigCASSave" and "__" in file_name: + stream_extension_guid = file_name.split("__", 1)[0].strip().lower() + if is_guid_text(stream_extension_guid): + inactive_stream_error = reject_inactive_extension_scope( + {"base_id": base_id_or_error, "extension_guid": stream_extension_guid}, method="modules.read" + ) + if inactive_stream_error: + return inactive_stream_error if module_id and owner_context is None: cache_config, _ = sql_config_for_base(base_id_or_error) cached_form_owner = metadata_form_owner_cache_lookup(cache_config, module_ref=module_id) @@ -23096,14 +29699,14 @@ def read_module(payload: dict[str, Any]) -> dict[str, Any]: }, "origin": module_origin_from_storage_table(table), } - if owner_context is None: + if owner_context is None and resolve_owner: owner_context = extension_module_owner_payload( base_id_or_error, module_id, table=table, timeout_seconds=int(payload.get("timeout_seconds") or 30), ) - if table in FORM_ELEMENT_SAVED_STATE_TABLES and ( + if resolve_owner and table in FORM_ELEMENT_SAVED_STATE_TABLES and ( owner_context is None or not isinstance(owner_context.get("owner"), dict) or not str((owner_context.get("owner") or {}).get("name") or "").strip() @@ -23126,9 +29729,43 @@ def read_module(payload: dict[str, Any]) -> dict[str, Any]: if isinstance(existing_context.get("origin"), dict) or isinstance(saved_context.get("origin"), dict): owner_context["origin"] = {**(existing_context.get("origin") or {}), **(saved_context.get("origin") or {})} if owner_context is None: - owner_context = {} + owner_context = { + "owner": { + "status": "partial", + "source": "direct_module_ref", + "diagnostics": { + "message": "Module payload was read directly. Owner enrichment was intentionally skipped; pass resolve_owner=true only when owner names are required.", + }, + }, + "origin": module_origin_from_storage_table(table), + } if not isinstance(owner_context.get("origin"), dict): owner_context["origin"] = module_origin_from_storage_table(table) + if "#form_module" in module_id: + decoded_form = metadata_form_decode({ + "base_id": base_id_or_error, "table": table, "form_guid": file_name, + "include_module_text": True, "include_parameters": False, + "timeout_seconds": int(payload.get("timeout_seconds") or 30), "_internal": True, + }) + profile = decoded_form.get("profile") if isinstance(decoded_form.get("profile"), dict) else {} + form_module = profile.get("module") if isinstance(profile.get("module"), dict) else {} + form_text = str(form_module.get("text") or "") + if decoded_form.get("status") == "ok" and form_text: + text_info = module_text_response(form_text, payload) + result = {"schema": "onec_module_read.v1", "status": text_info.pop("status", "ok"), "base_id": base_id_or_error, "source": {"kind": "live_metadata"}, "module": {"kind": "form_module", "name": "Модуль формы"}, "extraction": {"status": "ok", "source": "metadata_form_decode"}, **text_info} + # metadata_form_decode returns semantic text, not necessarily the + # raw container checksum. Read the already-addressed container + # once to expose the compare-and-set guard required by code.write. + form_data, _form_config, form_read_error = read_storage_file_bytes( + base_id_or_error, table, file_name, timeout_seconds=int(payload.get("timeout_seconds") or 30) + ) + if not form_read_error and form_data is not None: + result["write_preconditions"] = { + "expected_sha1": hashlib.sha1(form_data).hexdigest(), + "expected_text_sha1": str(result.get("content_sha1") or ""), + "status": "read_verified", + } + return merge_module_owner_context(result, owner_context) data, config, error = read_storage_file_bytes(base_id_or_error, table, file_name, timeout_seconds=int(payload.get("timeout_seconds") or 30)) if error: error["method"] = "modules.read" @@ -23190,6 +29827,16 @@ def read_module(payload: dict[str, Any]) -> dict[str, Any]: ), **text_info, } + # `content_sha1` is the SHA of the decoded BSL text. Make that + # distinction explicit for callers of code.write: `expected_sha1` + # protects the binary storage payload, whereas this guard protects + # the exact source text the agent reviewed. + result["write_preconditions"] = { + "expected_text_sha1": str(result.get("content_sha1") or ""), + "status": "read_verified", + } + if include_storage: + result["write_preconditions"]["expected_payload_sha1"] = hashlib.sha1(data).hexdigest() if descriptor_identity and descriptor_identity.get("name"): result["owner"] = { "status": "resolved", @@ -23258,6 +29905,13 @@ def read_module(payload: dict[str, Any]) -> dict[str, Any]: "extraction": extraction, **text_info, } + payload_sha1 = str(decoded.get("sha1") or hashlib.sha1(data).hexdigest()).strip().lower() + if payload_sha1: + result["write_preconditions"] = { + "expected_sha1": payload_sha1, + "expected_text_sha1": str(result.get("content_sha1") or ""), + "status": "read_verified", + } if bool(payload.get("include_container_preview", False)): container_preview_chars, _ = parse_int_argument(payload, "container_preview_chars", method="modules.read", default=1000, minimum=1) result["container_preview"] = container_text[: int(container_preview_chars or 1000)] @@ -23553,12 +30207,32 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: owner_scan_limit, owner_scan_limit_error = parse_int_argument(payload, "owner_scan_limit", method="modules.search", default=40, minimum=1, maximum=200) if owner_scan_limit_error: return owner_scan_limit_error + extension_route_probe_limit, extension_route_probe_limit_error = parse_int_argument( + payload, + "extension_route_probe_limit", + method="modules.search", + default=8, + minimum=1, + maximum=100, + ) + if extension_route_probe_limit_error: + return extension_route_probe_limit_error read_max_chars, read_max_chars_error = parse_int_argument(payload, "read_max_chars", method="modules.search", default=4000, minimum=1, maximum=100000) if read_max_chars_error: return read_max_chars_error timeout_seconds, timeout_error = parse_int_argument(payload, "timeout_seconds", method="modules.search", default=60, minimum=1) if timeout_error: return timeout_error + # `timeout_seconds` is a budget for the complete public search, not a + # fresh allowance for every owner/module probe. In particular, an + # extension-wide search can otherwise make several sequential 60-second + # DB calls before it even starts the text scan. + search_started = time.monotonic() + search_deadline = search_started + int(timeout_seconds or 60) + + def remaining_timeout_seconds() -> int: + return max(0, int(search_deadline - time.monotonic())) + extension_filter = str(payload.get("extension") or "").strip() extension_guid: str | None = None extension_owner_objects: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] @@ -23567,42 +30241,88 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: extension_owner_guids: list[str] = [] extension_active_object_guids: set[str] = set() extension_route_fallback_scan = False + extension_fast_route_unresolved = False extension_route_fallback_diagnostics: list[dict[str, Any]] = [] routine_name = str(payload.get("routine_name") or "").strip() routine_name_cf = normalize(routine_name) - if extension_filter: - if is_guid_text(extension_filter): - extension_guid = extension_filter.strip().lower() - else: - extensions_result = extension_map_by_guid(base_id_or_error) - wanted_extension = normalize(extension_filter) - for extension in extensions_result.values(): - if normalize(str(extension.get("name") or "")) == wanted_extension: - extension_guid = str(extension.get("guid") or "").strip().lower() - break - if not extension_guid: - return { - "schema": "onec_modules_search.v1", - "status": "not_found", - "base_id": base_id_or_error, - "source": {"kind": "live_metadata"}, - "query": { - "query": query, - "limit": limit, - "max_matches": limit, - "scan_limit": scan_limit, - "scope": scope, - "table": table, - "prefix": prefix, - "extension": extension_filter, - "routine_name": routine_name or None, - "include_storage": include_storage, - }, - "matches": [], - "counts": {"matches": 0, "scanned_files": 0, "scan_limit": scan_limit, "truncated": False, "tables_scanned": []}, - "diagnostics": {"message": f"Расширение `{extension_filter}` не найдено."}, - } - guid_sources, guid_error = extension_definition_guid_sources(base_id_or_error, timeout_seconds=int(timeout_seconds or 60)) + + def deadline_result(stage: str) -> dict[str, Any]: + return { + "schema": "onec_modules_search.v1", + "status": "partial", + "base_id": base_id_or_error, + "source": {"kind": "live_metadata"}, + "query": { + "query": query, + "limit": limit, + "max_matches": limit, + "scan_limit": scan_limit, + "scope": scope, + "table": table, + "extension": extension_filter or None, + "routine_name": routine_name or None, + "state": state, + "full_scan": full_scan, + "include_storage": include_storage, + }, + "matches": [], + "counts": {"matches": 0, "scanned_files": 0, "scan_limit": scan_limit, "truncated": True, "complete": False, "time_budget_exhausted": True}, + "diagnostics": { + "code": "time_budget_exhausted", + "stage": stage, + "elapsed_seconds": round(time.monotonic() - search_started, 3), + "timeout_seconds": int(timeout_seconds or 60), + "message": "Module search stopped at its total time budget; narrow it with an object ref/kind/name or run it as a background job.", + }, + } + + object_ordinal_selector = first_non_empty_arg(payload, "ordinal", "index", "object_index") + has_object_selector = bool(payload.get("guid") or payload.get("name") or (object_ordinal_selector is not None)) + if extension_filter and has_object_selector: + extension_guid, extension_error = extension_filter_to_guid(base_id_or_error, extension_filter, method="modules.search") + if extension_error: + return extension_error + payload = {**payload, "extension_guid": extension_guid} + if table == "auto": + table_for_read = "ConfigCAS" if state == "active" else "ConfigCASSave" + if extension_filter and not has_object_selector: + extension_guid, extension_error = extension_filter_to_guid(base_id_or_error, extension_filter, method="modules.search") + if extension_error: + return extension_error + # Reuse only prior exact owner/module routes for this extension. The + # BSL bytes are still read from current SQL below; the cache merely + # avoids enumerating every manifest descriptor before that read. + route_cache_config, _ = sql_config_for_base(base_id_or_error) + if route_cache_config: + try: + with cache_connection() as conn: + cached_routes = conn.execute( + """ + SELECT module_ref, module_payload_json + FROM metadata_module_owner_cache + WHERE server_key=? AND database_name=? AND module_table='ConfigCAS' + """, + (cache_server_key(route_cache_config), cache_database_name(route_cache_config)), + ).fetchall() + for cached_route in cached_routes: + try: + module_payload = json.loads(cached_route["module_payload_json"] or "{}") + except Exception: + module_payload = {} + cached_extension = module_payload.get("extension") if isinstance(module_payload, dict) else {} + if str((cached_extension or {}).get("guid") or "").strip().lower() != extension_guid: + continue + cached_ref = str(cached_route["module_ref"] or "").strip() + table_name, file_name, _ = parse_module_id(cached_ref) + if table_name == "ConfigCAS" and file_name: + extension_target_module_refs.add(cached_ref) + extension_target_files_by_table.setdefault(table_name, set()).add(file_name) + except Exception: + pass + remaining_timeout = remaining_timeout_seconds() + if remaining_timeout <= 0: + return deadline_result("extension_definition_guid_sources") + guid_sources, guid_error = extension_definition_guid_sources(base_id_or_error, timeout_seconds=remaining_timeout) if guid_error: guid_error["method"] = "modules.search" return guid_error @@ -23615,6 +30335,9 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: extension_owner_guids.append(definition_guid) break extension_owner_guids = sorted(set(guid for guid in extension_owner_guids if is_guid_text(guid))) + remaining_timeout = remaining_timeout_seconds() + if remaining_timeout <= 0: + return deadline_result("extension_objects_find") active_objects_result = extension_objects_find( { "base_id": base_id_or_error, @@ -23623,7 +30346,7 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: "limit": 500, "include_storage": False, "use_cache": True, - "timeout_seconds": int(timeout_seconds or 60), + "timeout_seconds": remaining_timeout, } ) if active_objects_result.get("status") == "ok": @@ -23632,7 +30355,25 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: for item in active_objects_result.get("objects") or [] if isinstance(item, dict) and str(item.get("guid") or "").strip() } - for owner_guid in extension_owner_guids: + extension_owner_guids_to_probe = extension_owner_guids + if not full_scan: + extension_owner_guids_to_probe = extension_owner_guids[:int(extension_route_probe_limit)] + if len(extension_owner_guids) > len(extension_owner_guids_to_probe): + extension_route_fallback_diagnostics.append( + { + "code": "extension_owner_route_probe_limited", + "probed": len(extension_owner_guids_to_probe), + "available": len(extension_owner_guids), + "message": "Fast extension search probes a bounded number of proven owner routes. Pass full_scan=true only for explicit deep discovery.", + } + ) + for owner_guid in ([] if extension_target_module_refs else extension_owner_guids_to_probe): + remaining_timeout = remaining_timeout_seconds() + if remaining_timeout <= 0: + extension_route_fallback_diagnostics.append( + {"code": "time_budget_exhausted", "stage": "extension_owner_module_routes", "resolved_owners": len(extension_owner_objects)} + ) + return deadline_result("extension_owner_module_routes") if table_for_read in {"ConfigSave", "ConfigCASSave"}: extension_owner_table = "ConfigCASSave" elif state in {"working", "save"}: @@ -23645,7 +30386,7 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: "guid": owner_guid, "table": extension_owner_table, "include_storage": True, - "timeout_seconds": int(timeout_seconds or 60), + "timeout_seconds": min(5, remaining_timeout), } ) if modules_result.get("status") != "ok": @@ -23662,12 +30403,15 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: table_name, file_name, _ = parse_module_id(module_id) if table_name and file_name: extension_target_files_by_table.setdefault(table_name, set()).add(file_name) - if not extension_target_module_refs: + if not extension_target_module_refs and full_scan: extension_route_fallback_scan = True + remaining_timeout = remaining_timeout_seconds() + if remaining_timeout <= 0: + return deadline_result("extension_manifest_fallback") manifests, manifest_diagnostics = live_extension_manifests( base_id_or_error, extension_guid=extension_guid, - timeout_seconds=int(timeout_seconds or 60), + timeout_seconds=remaining_timeout, ) manifest_keys = { str(entry.get("cas_key") or "").strip().lower() @@ -23685,6 +30429,14 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: "manifest_diagnostics": manifest_diagnostics, } ) + elif not extension_target_module_refs: + extension_fast_route_unresolved = True + extension_route_fallback_diagnostics.append( + { + "code": "extension_deep_scan_not_requested", + "message": "No exact extension module route was found in the fast bounded pass. Pass full_scan=true for a potentially slow ConfigCAS discovery scan, or warm extension/code caches first.", + } + ) cache_config, _ = sql_config_for_base(base_id_or_error) def extract_routine_text(text_value: str, wanted_routine_cf: str) -> tuple[str, int]: source_text = str(text_value or "") @@ -23715,13 +30467,40 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: return "\n".join(lines[start_line - 1 : end_line]), prefix_len return "", 0 - object_ordinal_selector = first_non_empty_arg(payload, "ordinal", "index", "object_index") - has_object_selector = bool(payload.get("guid") or payload.get("name") or (object_ordinal_selector is not None)) + def routine_name_for_match(text_value: str, snippet: dict[str, Any]) -> str | None: + """Return a routine only when the search offset falls in its proven block.""" + offset_value = snippet.get("offset") if isinstance(snippet, dict) else None + if offset_value is None: + return None + try: + from parser.bsl_validation import routine_blocks + routines = list(routine_blocks(str(text_value or ""))) + except Exception: + return None + lines = str(text_value or "").split("\n") + absolute_offset = int(offset_value) + for routine in routines: + start_line = int(routine.get("line_start") or 0) + end_line = int(routine.get("line_end") or 0) + if start_line < 1 or end_line < start_line: + continue + start_offset = sum(len(line) + 1 for line in lines[: start_line - 1]) + end_offset = sum(len(line) + 1 for line in lines[:end_line]) + if start_offset <= absolute_offset < end_offset: + return str(routine.get("name") or "").strip() or None + return None + if has_object_selector: modules_result = metadata_object_modules({**payload, "include_storage": True, "table": table_for_read}) if modules_result.get("status") != "ok": return public_error_result(modules_result, include_storage=include_storage, method="modules.search") object_info = modules_result.get("object") or {} + object_origin = object_info.get("origin") if isinstance(object_info.get("origin"), dict) else {} + object_extension = object_origin.get("extension") if isinstance(object_origin.get("extension"), dict) else {} + if not extension_guid and is_guid_text(str(object_extension.get("guid") or "")): + extension_guid = str(object_extension.get("guid") or "").lower() + if not extension_filter and object_extension.get("name"): + extension_filter = str(object_extension.get("name") or "") public_owner = { "status": "resolved", "kind": object_info.get("kind") or canonical_kind(str(payload.get("kind") or "")), @@ -23730,6 +30509,70 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: "guid": object_info.get("guid") or payload.get("guid"), } modules = [module for module in modules_result.get("modules") or [] if isinstance(module, dict)] + object_storage = object_info.get("storage") if isinstance(object_info.get("storage"), dict) else {} + forms_result = ( + {"status": "ok", "forms": []} + if any(isinstance(module.get("form"), dict) for module in modules) + else metadata_object_forms( + { + **payload, + "base_id": base_id_or_error, + "kind": public_owner.get("kind"), + "name": public_owner.get("name"), + "guid": public_owner.get("guid"), + "table": table_for_read, + **({"file_name": object_storage.get("file_name")} if object_storage.get("file_name") else {}), + "include_storage": True, + "timeout_seconds": int(timeout_seconds or 60), + } + ) + ) + known_module_refs = {str(module.get("module_id") or "") for module in modules} + for form in forms_result.get("forms") or []: + if not isinstance(form, dict): + continue + form_source = form.get("source") if isinstance(form.get("source"), dict) else {} + form_table = str(form_source.get("table") or table_for_read) + form_file_name = str(form_source.get("file_name") or "") + if form_table not in STORAGE_TABLES or not form_file_name: + continue + form_module_ref = f"{form_table}:{form_file_name}#form_module" + if form_module_ref in known_module_refs: + continue + form_payload = { + "guid": form.get("guid"), + "name": form.get("name"), + "synonym": next(iter((form.get("synonyms") or {}).values()), None) if isinstance(form.get("synonyms"), dict) else None, + } + modules.append( + { + "module_id": form_module_ref, + "table": form_table, + "file_name": form_file_name, + "kind": "form_module", + "name": "Модуль формы", + "form": form_payload, + "payload_role": "form_payload", + } + ) + known_module_refs.add(form_module_ref) + metadata_form_owner_cache_upsert( + cache_config, + base_id=base_id_or_error, + owner_kind=public_owner.get("kind"), + owner_name=public_owner.get("name"), + owner_guid=public_owner.get("guid"), + form_name=form_payload.get("name"), + form_guid=form_payload.get("guid"), + table=form_table, + file_name=form_file_name, + extension={ + "guid": extension_guid, + **({"name": extension_filter} if extension_filter and not is_guid_text(extension_filter) else {}), + } + if extension_guid + else None, + ) requested_module_ordinal = first_non_empty_arg(payload, "module_ordinal", "module_index", "module_number") module_rows: list[tuple[int, dict[str, Any]]] = list(enumerate(modules, start=1)) command_module_rows: list[dict[str, Any]] = [] @@ -23783,8 +30626,60 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: "diagnostics": {"message": f"Module ordinal {module_ordinal} was not found for the selected object."}, } module_rows = [(int(module_ordinal or 1), modules[requested_index])] - matches = [] + state_module_rows: list[tuple[int, dict[str, Any]]] = [] for ordinal, module in module_rows: + module_id = str(module.get("module_id") or "").strip() + module_table, _module_file_name, _module_stream_index = parse_module_id(module_id) + candidate_refs: list[str] = [] + if module_table in {"Config", "ConfigCAS"}: + saved_ref = code_read_saved_counterpart_module_ref(base_id_or_error, module_id, payload) + if state == "active": + candidate_refs = [module_id] + elif state == "save": + candidate_refs = [saved_ref] if saved_ref else [] + elif state == "both": + candidate_refs = [ref for ref in (saved_ref, module_id) if ref] + else: + # The Designer working state prefers an existing saved + # module, but falls back to the active module when the + # saved layer is absent or does not contain the query. + candidate_refs = [ref for ref in (saved_ref, module_id) if ref] + elif module_table in {"ConfigSave", "ConfigCASSave"}: + active_ref = code_read_active_counterpart_module_ref(module_id) + if state == "active": + candidate_refs = [active_ref] if active_ref else [] + elif state == "save": + candidate_refs = [module_id] + elif state == "both": + candidate_refs = [ref for ref in (module_id, active_ref) if ref] + else: + candidate_refs = [ref for ref in (module_id, active_ref) if ref] + else: + candidate_refs = [module_id] + seen_candidate_refs: set[str] = set() + for candidate_ref in candidate_refs: + if not candidate_ref or candidate_ref in seen_candidate_refs: + continue + seen_candidate_refs.add(candidate_ref) + candidate_table, candidate_file_name, candidate_stream_index = parse_module_id(candidate_ref) + state_module_rows.append( + ( + ordinal, + { + **module, + "module_id": candidate_ref, + "table": candidate_table, + "file_name": candidate_file_name, + **({"stream_index": candidate_stream_index} if candidate_stream_index is not None else {}), + }, + ) + ) + module_rows = state_module_rows + matches = [] + matched_working_ordinals: set[int] = set() + for ordinal, module in module_rows: + if state != "both" and ordinal in matched_working_ordinals: + continue module_id = str(module.get("module_id") or "") table_name, file_name, stream_index = parse_module_id(module_id) if not table_name or not file_name: @@ -23816,22 +30711,43 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: continue if query_cf not in routine_text.casefold(): continue + form_payload = module.get("form") if isinstance(module.get("form"), dict) else {} public_module = public_module_with_qualified_name(module, owner=public_owner, include_storage=False, ordinal=ordinal, owner_kind=public_owner.get("kind")) + if form_payload: + form_name = str(form_payload.get("name") or "") + qualified_form_module_name = ".".join( + part + for part in [public_owner.get("name"), "Форма", form_name, "Модуль формы"] + if part + ) + public_module = { + **public_module, + "kind": "form_module", + "name": "Модуль формы", + "form": form_name or None, + "qualified_name": qualified_form_module_name, + "display_name": qualified_form_module_name, + } snippet = text_snippet(routine_text, query) if routine_name_cf and routine_offset: snippet["offset"] = (snippet.get("offset") or 0) + routine_offset if snippet.get("offset") is not None else snippet["offset"] - match = { - "score": 1.0, - "snippet": snippet, - "owner": public_owner, - "module": { - "name": public_module.get("name"), - "module_ordinal": ordinal, - "form": None, - }, - **({"qualified_name": public_module.get("qualified_name")} if public_module.get("qualified_name") else {}), - **({"display_name": public_module.get("display_name")} if public_module.get("display_name") else {}), - "read_selector": enrich_selector_with_object_ref( + if form_payload: + read_selector = enrich_selector_with_object_ref( + { + "base_id": base_id_or_error, + "method": "modules.read", + "kind": public_owner.get("kind"), + "guid": public_owner.get("guid"), + "name": public_owner.get("name"), + "form": form_payload.get("name"), + "module_ref": module_id, + "preview": True, + "max_chars": int(read_max_chars or 4000), + }, + public_owner, + ) + else: + read_selector = enrich_selector_with_object_ref( { "base_id": base_id_or_error, "method": "modules.read", @@ -23842,9 +30758,42 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: "max_chars": int(read_max_chars or 4000), }, public_owner, + ) + match = { + "score": 1.0, + "snippet": snippet, + "owner": public_owner, + "module": { + "kind": public_module.get("kind"), + "name": public_module.get("name"), + "module_ordinal": ordinal, + "form": form_payload.get("name") if form_payload else None, + **({"role_status": public_module.get("role_status")} if public_module.get("role_status") else {}), + **({"configurator_path_status": public_module.get("configurator_path_status")} if public_module.get("configurator_path_status") else {}), + }, + **({"qualified_name": public_module.get("qualified_name")} if public_module.get("qualified_name") else {}), + **({"display_name": public_module.get("display_name")} if public_module.get("display_name") else {}), + "read_selector": read_selector, + "origin": ( + { + "source": "extension", + "presentation": "Расширение", + "extension": { + "guid": extension_guid, + **({"name": extension_filter} if extension_filter and not is_guid_text(extension_filter) else {}), + }, + "status": "ok", + } + if extension_guid + else module_origin_from_storage_table(table_name) ), - "origin": module_origin_from_storage_table(table_name), } + if table_name in {"ConfigSave", "ConfigCASSave"}: + match["activation_state"] = "saved_state" + match["current_state"] = {"source": "saved_state", "activation_state": "not_activated"} + else: + match["activation_state"] = "active" + match["current_state"] = {"source": "active", "activation_state": "active"} if include_storage: match.update({"module_id": module_id, "table": table_name, "file_name": file_name, **({"stream_index": stream_index} if stream_index is not None else {})}) if routine_name_cf: @@ -23853,6 +30802,7 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: match["module"]["routine_name"] = routine_name match["query"] = {"routine_name": routine_name} matches.append(match) + matched_working_ordinals.add(ordinal) if len(matches) >= limit: break if len(matches) < limit: @@ -23950,6 +30900,23 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: "owner_indexed_module_refs": len(matches), }, } + if extension_fast_route_unresolved: + return { + "schema": "onec_modules_search.v1", + "status": "partial", + "base_id": base_id_or_error, + "source": {"kind": "live_metadata"}, + "query": { + "query": query, "limit": limit, "max_matches": limit, + "scan_limit": scan_limit, "scope": scope, "table": table, + "prefix": prefix, "extension": extension_filter or None, + "routine_name": routine_name or None, "state": state, + "full_scan": full_scan, "include_storage": include_storage, + }, + "matches": [], + "counts": {"matches": 0, "scanned_files": 0, "scan_limit": scan_limit, "truncated": True, "complete": False}, + "diagnostics": {"code": "extension_deep_scan_not_requested", "details": extension_route_fallback_diagnostics}, + } scan_budget = int(scan_limit or 300) if table == "auto": if scope == "all": @@ -23998,25 +30965,33 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: scanned_tables: list[str] = [] all_payloads: dict[str, tuple[dict[str, bytes], dict[str, Any] | None]] = {} matches = [] + # The same physical file key can appear in both scanned layers in tests or + # after a platform copy. Keep the first (higher-priority) layer rather + # than returning duplicate logical code hits. + matched_logical_streams: set[str] = set() skipped_active_extension_fallback = False - for candidate_table in tables_to_scan: + for table_index, candidate_table in enumerate(tables_to_scan): 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} + # In automatic cross-layer search a single table must not consume the + # entire scan budget before base Config or extension ConfigCAS is even + # considered. Explicit scopes keep their full budget. + remaining_tables = max(1, len(tables_to_scan) - table_index) + table_budget = remaining_budget if scope != "auto" or table != "auto" else max(1, remaining_budget // remaining_tables) + files_payload = {"base_id": base_id_or_error, "table": candidate_table, "limit": table_budget, "_internal": True} effective_prefix = prefix if not effective_prefix and extension_filter and extension_guid and candidate_table in {"ConfigCAS", "ConfigCASSave"}: effective_prefix = f"{extension_guid}__" if effective_prefix: files_payload["prefix"] = effective_prefix - manifest_direct_files = ( - extension_route_fallback_scan - and candidate_table == "ConfigCAS" - and bool(extension_target_files_by_table.get("ConfigCAS")) + exact_extension_files = ( + candidate_table in {"ConfigCAS", "ConfigCASSave"} + and bool(extension_target_files_by_table.get(candidate_table)) ) - if manifest_direct_files: - file_names = sorted(extension_target_files_by_table.get("ConfigCAS") or set())[:remaining_budget] + if exact_extension_files: + file_names = sorted(extension_target_files_by_table.get(candidate_table) or set())[:remaining_budget] candidate_files = {"status": "ok", "files": [{"FileName": name} for name in file_names], "truncated": False} else: candidate_files = storage_files_list(files_payload) @@ -24041,7 +31016,7 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: error["method"] = "modules.search" return error all_payloads[candidate_table] = (payloads, config) - if table == "auto" and scope in {"auto", "modules", "configcas"} and state != "both": + if table == "auto" and scope in {"modules", "configcas"} and state != "both": break if table == "auto" and scope == "all" and candidate_files.get("truncated"): break @@ -24089,16 +31064,28 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: } def metadata_owner_cache_lookup(module_id: str) -> dict[str, Any] | None: - if not cache_owner_enabled: - return None + # The first resolved ConfigCAS hit may be the row that warms this + # cache. Do not make lookup conditional on a pre-existing row: that + # turned a successful per-module resolver invocation into an + # owner_unresolved response for every first extension search. cached = metadata_module_owner_cache_lookup(cache_config, module_id) if not cached: return None owner_payload = cached.get("owner") or {} module_payload = cached.get("module_payload") or {} - if not isinstance(owner_payload, dict) or not owner_payload.get("kind") or not is_guid_text(owner_payload.get("guid") or ""): + extension_payload = module_payload.get("extension") if isinstance(module_payload.get("extension"), dict) else {} + # An extension descriptor can prove the exact owner GUID and name + # before its metadata kind codec is available. Preserve that proven + # ownership instead of degrading it to owner_unresolved; the missing + # kind remains explicit and prevents a name-first write target. + if not isinstance(owner_payload, dict) or not owner_payload.get("name") or not is_guid_text(owner_payload.get("guid") or ""): return None - return { + cached_table, _cached_file_name, _cached_stream_index = parse_module_id(module_id) + if cached_table == "ConfigCAS" and not owner_payload.get("kind"): + # A pre-existing partial extension cache row must be upgraded from + # its current manifest descriptor before it becomes a search hit. + return None + result = { "owner": { "status": "resolved", "kind": owner_payload.get("kind"), @@ -24121,6 +31108,14 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: "max_chars": int(read_max_chars or 4000), }, } + if extension_payload.get("guid") or extension_payload.get("name"): + result["origin"] = { + "source": "extension", + "presentation": "Расширение", + "extension": extension_payload, + "status": "ok" if extension_payload.get("name") else "extension_unresolved", + } + return result def build_owner_index() -> None: if not resolve_owners or cache_owner_enabled: @@ -24263,10 +31258,22 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: *, stream_index: int | None = None, bsl_offset: int | None = None, + routine_name_for_hit: str | None = None, ) -> dict[str, Any]: - cached_owner = metadata_owner_cache_lookup(module_id) - cached_form_owner = metadata_form_owner_cache_lookup(cache_config, module_ref=module_id) if not cached_owner else None module_table, module_file_name, _ = parse_module_id(module_id) + cached_owner = metadata_owner_cache_lookup(module_id) + cached_origin = cached_owner.get("origin") if isinstance((cached_owner or {}).get("origin"), dict) else {} + if resolve_owners and cache_config and ( + not cached_owner or (module_table == "ConfigCAS" and not cached_origin.get("extension")) + ): + metadata_module_owner_resolve_from_ref( + base_id_or_error, + cache_config, + module_id, + timeout_seconds=int(timeout_seconds or 60), + ) + cached_owner = metadata_owner_cache_lookup(module_id) + cached_form_owner = metadata_form_owner_cache_lookup(cache_config, module_ref=module_id) if not cached_owner else None read_selector = { "base_id": base_id_or_error, "method": "modules.read", @@ -24321,10 +31328,14 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: cached_module = cached_owner.get("module") or {} if cached_module.get("name"): match["module"]["name"] = cached_module.get("name") + if cached_module.get("module_type"): + match["module"]["kind"] = cached_module.get("module_type") if cached_module.get("module_ordinal"): match["module"]["module_ordinal"] = cached_module.get("module_ordinal") if cached_owner.get("module_payload", {}).get("stream_index") is not None: match["module"]["stream_index"] = cached_owner.get("module_payload", {}).get("stream_index") + if isinstance(cached_owner.get("origin"), dict): + match["origin"] = cached_owner["origin"] if cached_form_owner: cached_form = cached_form_owner.get("form") if isinstance(cached_form_owner.get("form"), dict) else {} match["module"]["name"] = "Модуль формы" @@ -24337,6 +31348,8 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: } if stream_index is not None: match["module"]["stream_index"] = stream_index + if routine_name_for_hit: + match["module"]["routine_name"] = routine_name_for_hit if module_table in {"ConfigSave", "ConfigCASSave"}: saved_context = saved_state_public_module_context( base_id=base_id_or_error, @@ -24396,7 +31409,16 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: if extension_target_module_refs and module_id not in extension_target_module_refs: continue snippet = text_snippet(bsl_search_text, query) - match = public_module_match(module_id, snippet, bsl_offset=int(extraction.get("bsl_offset") or 0)) + match = public_module_match( + module_id, + snippet, + bsl_offset=int(extraction.get("bsl_offset") or 0), + routine_name_for_hit=routine_name_for_match(bsl_search_text, snippet), + ) + logical_stream_key = f"{file_name}#container" + if logical_stream_key in matched_logical_streams: + continue + matched_logical_streams.add(logical_stream_key) match["extraction"] = { "status": "ok", "source": "bsl_text", @@ -24447,7 +31469,16 @@ def search_modules(payload: dict[str, Any]) -> dict[str, Any]: snippet = text_snippet(stream_search_text, query) if routine_name_cf and routine_offset: snippet["offset"] = (snippet.get("offset") or 0) + routine_offset if snippet.get("offset") is not None else snippet["offset"] - match = public_module_match(module_id, snippet, stream_index=index) + match = public_module_match( + module_id, + snippet, + stream_index=index, + routine_name_for_hit=routine_name_for_match(stream_search_text, snippet), + ) + logical_stream_key = f"{file_name}#stream:{index}" + if logical_stream_key in matched_logical_streams: + continue + matched_logical_streams.add(logical_stream_key) if include_storage: if routine_name_cf: match["module"]["routine_name"] = routine_name @@ -24777,6 +31808,64 @@ def code_saved_state_common_form_read(payload: dict[str, Any], *, include_text: return result +def code_read_indexed_fragment( + payload: dict[str, Any], + *, + base_id: str, + module_ref: str, + include_text: bool, + current_state: dict[str, Any], +) -> dict[str, Any] | None: + """Return a focused BSL selection from the local code index. + + The index stores a previously decoded module text and routine boundaries. + It is intentionally an opt-in fast snapshot: a caller needing a live SQL + proof keeps the normal read path (``prefer_code_index=false``). This lets + routine navigation avoid decompressing and decoding a large ConfigCAS + container for every click without presenting cache data as live readback. + """ + if not truthy(payload.get("prefer_code_index", "1")): + return None + if not (str(payload.get("routine_name") or "").strip() or payload.get("line_start") is not None or payload.get("line_end") is not None): + return None + config, _config_error = sql_config_for_base(base_id) + if not config: + return None + with cache_connection() as conn: + row = conn.execute( + """ + SELECT * FROM metadata_code_index_cache + WHERE server_key=? AND database_name=? AND module_ref=? + LIMIT 1 + """, + (cache_server_key(config), cache_database_name(config), module_ref), + ).fetchone() + if not row: + return None + cached = code_index_row_payload(row, config) + text = str(cached.get("text") or "") + if not text: + return None + text_info = module_text_response(text, payload) + owner = cached.get("owner") if isinstance(cached.get("owner"), dict) else {} + extension = owner.get("extension") if isinstance(owner.get("extension"), dict) else {} + result: dict[str, Any] = { + "schema": "onec_code_read.v1", + "method": "code.read", + "status": text_info.pop("status", "ok"), + "base_id": base_id, + "source": {"kind": "code_index_cache", "target": "module_or_routine", "live_sql_decoded": False}, + "current_state": current_state, + "module": {"name": "Модуль БСЛ", "module_type": "bsl_module", "form": owner.get("form")}, + "resolved_owner": {"status": "resolved" if owner.get("guid") else "partial", "kind": owner.get("kind"), "name": owner.get("name"), "guid": owner.get("guid")}, + "freshness": {"status": "cache_snapshot", "verified_against_sql": False, "payload_sha1": cached.get("payload_sha1"), "text_sha1": cached.get("text_sha1"), "last_verified_at": cached.get("last_verified_at")}, + **text_info, + } + if extension.get("guid") or extension.get("name"): + result["origin"] = {"source": "extension", "presentation": "Расширение", "extension": extension, "status": "ok" if extension.get("name") else "extension_unresolved"} + return result + + def code_read_layer_item(result: dict[str, Any] | None, *, source: str, include_text: bool) -> dict[str, Any]: current_state = ( {"source": "saved_state", "activation_state": "not_activated"} @@ -24798,20 +31887,137 @@ def code_read_layer_item(result: dict[str, Any] | None, *, source: str, include_ item["selection"] = result.get("selection") if isinstance(result.get("diagnostics"), dict): item["diagnostics"] = result.get("diagnostics") + if isinstance(result.get("counterpart_resolution"), dict): + item["counterpart_resolution"] = result.get("counterpart_resolution") return item +def code_read_active_counterpart_module_ref(module_ref: str) -> str | None: + table, file_name, stream_index = parse_module_id(str(module_ref or "").strip()) + active_table = SAVED_STATE_SOURCE_BY_TARGET.get(str(table or "")) + if not active_table or not file_name: + return None + return f"{active_table}:{file_name}" + (f"#stream:{stream_index}" if stream_index is not None else "") + + +def code_read_saved_counterpart_module_ref(base_id: str, module_ref: str, payload: dict[str, Any]) -> str | None: + table, _file_name, stream_index = parse_module_id(str(module_ref or "").strip()) + saved_table = {"Config": "ConfigSave", "ConfigCAS": "ConfigCASSave"}.get(str(table or "")) + if not saved_table: + return None + config, _ = sql_config_for_base(base_id) + form_context = metadata_form_owner_cache_lookup(config, module_ref=module_ref) + if not form_context and payload.get("form"): + form_context = metadata_form_owner_cache_lookup( + config, + owner_kind=str(payload.get("kind") or payload.get("object_type") or ""), + form_name=str(payload.get("form") or ""), + extension=str(payload.get("extension") or payload.get("extension_guid") or "") or None, + ) + if not isinstance(form_context, dict): + return None + form = form_context.get("form") if isinstance(form_context.get("form"), dict) else {} + extension = form_context.get("extension") if isinstance(form_context.get("extension"), dict) else {} + form_guid = str(form.get("guid") or "").strip().lower() + extension_guid = str(extension.get("guid") or payload.get("extension_guid") or "").strip().lower() + if not is_guid_text(form_guid): + return None + if saved_table == "ConfigCASSave": + if not is_guid_text(extension_guid): + return None + saved_file_name = f"{extension_guid}__{form_guid}.0" + else: + saved_file_name = f"{form_guid}.0" + fragment = str(module_ref or "").split("#", 1)[1] if "#" in str(module_ref or "") else "" + if fragment in {"form_module", "bsl", "bsl_container"}: + return f"{saved_table}:{saved_file_name}#{fragment}" + if stream_index is not None: + return f"{saved_table}:{saved_file_name}#stream:{stream_index}" + return f"{saved_table}:{saved_file_name}" + + +def code_read_active_counterpart_selector( + saved_result: dict[str, Any] | None, + payload: dict[str, Any], + *, + module_ref: str, +) -> dict[str, Any]: + if not isinstance(saved_result, dict): + return {} + owner = saved_result.get("owner") if isinstance(saved_result.get("owner"), dict) else {} + resolved_owner = saved_result.get("resolved_owner") if isinstance(saved_result.get("resolved_owner"), dict) else {} + if not owner and isinstance(resolved_owner.get("owner"), dict): + owner = resolved_owner["owner"] + module = saved_result.get("module") if isinstance(saved_result.get("module"), dict) else {} + origin = saved_result.get("origin") if isinstance(saved_result.get("origin"), dict) else {} + extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} + saved_table, saved_file_name, _saved_stream_index = parse_module_id(module_ref) + active_table = SAVED_STATE_SOURCE_BY_TARGET.get(str(saved_table or "")) + if not active_table: + return {} + + kind = canonical_kind(str(owner.get("kind") or payload.get("object_type") or payload.get("kind") or "")) + name = str(owner.get("name") or payload.get("object_name") or payload.get("name") or "").strip() + guid = str(owner.get("guid") or payload.get("object_guid") or payload.get("guid") or "").strip().lower() + if not (name or (guid and is_guid_text(guid))): + return {} + + extension_guid = str(extension.get("guid") or "").strip().lower() + if not extension_guid and saved_table == "ConfigCASSave" and "__" in str(saved_file_name or ""): + prefix = str(saved_file_name or "").split("__", 1)[0].strip().lower() + if is_guid_text(prefix): + extension_guid = prefix + + selector: dict[str, Any] = { + "table": active_table, + **({"kind": kind} if kind else {}), + **({"name": name} if name else {}), + **({"guid": guid} if is_guid_text(guid) else {}), + **({"extension_guid": extension_guid} if is_guid_text(extension_guid) else {}), + } + module_ordinal = module.get("module_ordinal") + if module_ordinal not in {None, ""}: + selector["module_ordinal"] = module_ordinal + else: + module_name = str(module.get("name") or module.get("module_name") or "").strip() + if module_name and normalize(module_name) not in {normalize("Модуль БСЛ"), normalize("BSL module")}: + selector["module"] = module_name + return selector + + def code_read_both_response(payload: dict[str, Any], *, base_id: str, saved_result: dict[str, Any] | None, active_result: dict[str, Any] | None, include_text: bool) -> dict[str, Any]: saved_layer = code_read_layer_item(saved_result, source="saved_state", include_text=include_text) active_layer = code_read_layer_item(active_result, source="active", include_text=include_text) ok_sources = [layer["source"] for layer in (saved_layer, active_layer) if layer.get("status") in {"ok", "summary", "text"}] - saved_text = saved_layer.get("text") if isinstance(saved_layer.get("text"), str) else None - active_text = active_layer.get("text") if isinstance(active_layer.get("text"), str) else None + saved_text = saved_result.get("text") if isinstance(saved_result, dict) and isinstance(saved_result.get("text"), str) else None + active_text = active_result.get("text") if isinstance(active_result, dict) and isinstance(active_result.get("text"), str) else None + module_text_comparison = ( + bsl_module_text_comparison( + active_text, + saved_text, + routine_name=str(payload.get("routine_name") or ""), + include_text_diff=bool(payload.get("include_text_diff", True)), + max_text_diff_lines=int(payload.get("max_text_diff_lines") or 200), + ) + if saved_text is not None and active_text is not None + else None + ) comparison = { "saved_status": saved_layer.get("status"), "active_status": active_layer.get("status"), "both_present": bool(saved_text is not None and active_text is not None), - "differs": bool(saved_text is not None and active_text is not None and saved_text != active_text), + "differs": module_text_comparison.get("differs") if module_text_comparison else False, + "text_same": module_text_comparison.get("same") if module_text_comparison else None, + "module_text_same": module_text_comparison.get("same") if module_text_comparison else None, + **( + { + "active_text_sha1": module_text_comparison.get("active_sha1"), + "saved_text_sha1": module_text_comparison.get("saved_sha1"), + "scope": module_text_comparison.get("scope"), + } + if module_text_comparison + else {} + ), } result = { "schema": "onec_code_read.v1", @@ -24828,6 +32034,7 @@ def code_read_both_response(payload: dict[str, Any], *, base_id: str, saved_resu }, "layers": [saved_layer, active_layer], "comparison": comparison, + **({"module_text": module_text_comparison} if module_text_comparison else {}), } if include_text: if saved_text is not None: @@ -24923,6 +32130,12 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: include_context, include_context_error = strict_bool_argument(payload, "include_context", method="code.search", default=True) if include_context_error: return include_context_error + prepare_replace, prepare_replace_error = strict_bool_argument(payload, "prepare_replace", method="code.search", default=False) + if prepare_replace_error: + return prepare_replace_error + anchor_chars, anchor_chars_error = parse_int_argument(payload, "anchor_chars", method="code.search", default=120, minimum=1, maximum=2000) + if anchor_chars_error: + return anchor_chars_error include_storage, include_storage_error = strict_include_storage(payload, "code.search") if include_storage_error: return include_storage_error @@ -24977,7 +32190,11 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: search_payload = { "base_id": base_id_or_error, "query": query, - "include_storage": bool(include_storage), + # The public response may hide storage diagnostics, but the resolver + # needs the exact stream from this already-scoped search hit to issue + # a safe write capability. Do not let the presentation flag discard + # that internal proof before write_selector is assembled below. + "include_storage": True, "scan_limit": int(scan_limit or 300), "limit": int(limit or 25) + int(offset or 0), "routine_name": payload.get("routine_name"), @@ -24987,6 +32204,7 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: "state": state, "max_chars": payload.get("max_chars"), "resolve_owners": True, + "full_scan": payload.get("full_scan", False), "timeout_seconds": int(timeout_seconds or 60), **({"kind": object_selector.get("kind")} if object_selector.get("kind") else {}), **({"name": object_selector.get("name")} if object_selector.get("name") is not None else {}), @@ -25002,7 +32220,37 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: modules_result = search_modules(search_payload) if modules_result.get("status") == "error": return modules_result - raw_matches = modules_result.get("matches") or [] + raw_matches = [item for item in (modules_result.get("matches") or []) if isinstance(item, dict)] + # Once the caller has specified an extension, object and form, global BSL + # hits without that proven owner are diagnostic noise, not code candidates. + # This prevents a stale ConfigCAS payload with identical text from becoming + # the apparent source for a later write. + requested_form = str(payload.get("form") or payload.get("form_name") or "").strip() + strict_owner_scope = bool( + requested_form + and payload.get("extension") + and object_selector.get("kind") + and object_selector.get("name") + ) + excluded_unbound_matches = 0 + if strict_owner_scope: + scoped_matches: list[dict[str, Any]] = [] + for match in raw_matches: + owner = match.get("owner") if isinstance(match.get("owner"), dict) else {} + module = match.get("module") if isinstance(match.get("module"), dict) else {} + form = module.get("form") if isinstance(module.get("form"), dict) else {} + origin = match.get("origin") if isinstance(match.get("origin"), dict) else {} + origin_extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} + if ( + canonical_kind(str(owner.get("kind") or "")) == canonical_kind(str(object_selector.get("kind") or "")) + and normalize(str(owner.get("name") or "")) == normalize(str(object_selector.get("name") or "")) + and normalize(str(form.get("name") or "")) == normalize(requested_form) + and normalize(str(origin_extension.get("name") or "")) == normalize(str(payload.get("extension") or "")) + ): + scoped_matches.append(match) + else: + excluded_unbound_matches += 1 + raw_matches = scoped_matches sliced = raw_matches[int(offset or 0) : int(offset or 0) + int(limit or 25)] items: list[dict[str, Any]] = [] for match in sliced: @@ -25012,6 +32260,15 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: item_read_selector = dict(match.get("read_selector") or {}) if item_read_selector: item_read_selector["method"] = "code.read" + # The selector must reread the same layer that produced the hit. + # Without this, an active ConfigCAS hit was silently reread from + # ConfigCASSave by code.read's effective-workings default and + # reported as source_missing. + activation_state = str(match.get("activation_state") or "") + item_read_selector.setdefault( + "configuration_view", + "effective_working" if activation_state.startswith("saved") else "runtime_applied", + ) item_line = None item_column = None if truthy(include_line_numbers): @@ -25045,8 +32302,7 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: context_text = "" elif offset_value is not None and context_text: item_line, item_column = _snippet_to_line_column(context_text, int(offset_value)) - items.append( - { + item = { "match": str(context_text if context_text else ""), "line": item_line, "column": item_column, @@ -25062,7 +32318,105 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: "read_selector": item_read_selector, "source": item_read_selector, } - ) + # This remains a public, name-first selector: physical ConfigCAS keys + # never leak into a write request. It is emitted only after the same + # search result resolved one extension owner and one module ordinal; + # metadata.write still performs its normal live preflight/SHA guard. + item_owner = item.get("resolved_owner") if isinstance(item.get("resolved_owner"), dict) else {} + item_origin = item.get("origin") if isinstance(item.get("origin"), dict) else {} + item_extension = item_origin.get("extension") if isinstance(item_origin.get("extension"), dict) else {} + item_module = item.get("module") if isinstance(item.get("module"), dict) else {} + if ( + item_owner.get("status") == "resolved" + and item_owner.get("kind") + and item_owner.get("name") + and item_extension.get("name") + and item_module.get("module_ordinal") is not None + ): + routine_name = str(item_module.get("routine_name") or "").strip() + canonical_path = ( + ".".join([RU_KIND.get(str(item_owner.get("kind") or ""), str(item_owner.get("kind") or "")), str(item_owner.get("name")), routine_name]) + if routine_name + else None + ) + # ``search_modules`` intentionally removes physical coordinates + # from its public read selector. The match itself, however, is + # the already resolved active module that produced this hit. Keep + # that exact handle in the adapter-issued write capability: a + # later code.write must not fall back to ordinal/module-role + # discovery and thereby lose an otherwise proven extension route. + concrete_module_ref = str( + match.get("module_id") + or item_read_selector.get("module_ref") + or item_read_selector.get("module_id") + or "" + ).strip() + write_target = { + "kind": "module", + "ref": object_selector_ref(item_owner.get("kind"), item_owner.get("name")), + "extension": item_extension.get("name"), + "module_ordinal": item_module.get("module_ordinal"), + **({"form": item_module.get("form")} if item_module.get("form") else {}), + **({"module": item_module.get("kind") or "bsl_module"}), + **({"routine_name": routine_name} if routine_name else {}), + **({"canonical_path": canonical_path} if canonical_path else {}), + **({"module_ref": concrete_module_ref, "concrete_reference": concrete_module_ref} if concrete_module_ref else {}), + "layer_id": f"extension:{item_extension.get('guid')}" if item_extension.get("guid") else None, + "preflight_required": True, + } + item["metadata_write_target"] = write_target + item["write_selector"] = {"target": write_target} + if prepare_replace and item_read_selector: + candidate_read = code_read( + { + **item_read_selector, + "include_text": True, + "max_chars": 1_000_000, + "include_routines": False, + "include_summary": False, + } + ) + candidate_text = candidate_read.get("text") if isinstance(candidate_read, dict) else None + if isinstance(candidate_text, str): + candidates = code_replacement_candidates( + candidate_text, + str(query_value or ""), + anchor_chars=int(anchor_chars or 120), + ) + owner = item.get("resolved_owner") if isinstance(item.get("resolved_owner"), dict) else {} + owner_kind = canonical_kind(str(owner.get("kind") or "")) + owner_name = str(owner.get("name") or "").strip() + origin = item.get("origin") if isinstance(item.get("origin"), dict) else {} + extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} + write_selector = { + "base_id": base_id_or_error, + **({"ref": f"{owner_kind}.{owner_name}"} if owner_kind and owner_name else {}), + **({"extension": extension.get("name")} if extension.get("name") else {}), + **({"routine_name": item.get("module", {}).get("routine_name")} if isinstance(item.get("module"), dict) and item["module"].get("routine_name") else {}), + **({"expected_text_sha1": candidate_read.get("content_sha1")} if candidate_read.get("content_sha1") else {}), + } + # Reuse the exact target created above, rather than creating + # a reduced name-only selector for a replacement candidate. + # The concrete reference remains safe only because it is + # issued from this resolved search hit and code.write checks + # its owner, extension and development layer before writing. + item_write_selector = item.get("write_selector") if isinstance(item.get("write_selector"), dict) else {} + item_write_target = item_write_selector.get("target") if isinstance(item_write_selector.get("target"), dict) else {} + for selector_key in ( + "ref", "extension", "routine_name", "canonical_path", + "layer_id", "concrete_reference", "module_ref", + ): + selector_value = item_write_target.get(selector_key) + if selector_value not in {None, ""}: + write_selector[selector_key] = selector_value + for candidate in candidates: + candidate["write_selector"] = write_selector + item["replacement_candidates"] = candidates + item["replacement_candidate_count"] = len(candidates) + else: + item["replacement_candidates"] = [] + item["replacement_diagnostics"] = {"code": "module_read_failed", "message": "Unable to read the complete module for contextual replacement candidates."} + items.append(item) if not truthy(include_context): item = items[-1] item.pop("context", None) @@ -25088,6 +32442,8 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: "include_storage": bool(include_storage), "include_line_numbers": bool(include_line_numbers), "include_context": bool(include_context), + "prepare_replace": bool(prepare_replace), + **({"anchor_chars": int(anchor_chars or 120)} if prepare_replace else {}), }, "items": items, "counts": { @@ -25099,6 +32455,7 @@ def code_search(payload: dict[str, Any]) -> dict[str, Any]: "complete": bool(modules_result.get("counts", {}).get("complete", not modules_result.get("counts", {}).get("truncated"))), "scan_limit_hit": bool(modules_result.get("counts", {}).get("scan_limit_hit", modules_result.get("counts", {}).get("truncated"))), "owner_resolved": int(modules_result.get("counts", {}).get("owner_resolved") or 0), + "excluded_unbound_matches": excluded_unbound_matches, "owner_unresolved": int(modules_result.get("counts", {}).get("owner_unresolved") or 0), "owner_scan_limit_hit": bool(modules_result.get("counts", {}).get("owner_scan_limit_hit", False)), "owner_indexed_module_refs": int(modules_result.get("counts", {}).get("owner_indexed_module_refs") or 0), @@ -25141,15 +32498,54 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: parsed_table, _parsed_file_name, _parsed_stream = parse_module_id(module_ref_value) module_ref_table = parsed_table saved_state_module_ref = bool(module_ref_table in FORM_ELEMENT_SAVED_STATE_TABLES) + mapped_saved_module_ref = ( + code_read_saved_counterpart_module_ref(base_id_or_error, module_ref_value, payload) + if module_ref_value and not saved_state_module_ref and state in {"working", "save", "both"} + else None + ) + saved_read_module_ref = module_ref_value if saved_state_module_ref else mapped_saved_module_ref + indexed_module_ref = ( + saved_read_module_ref + if state in {"working", "save"} and saved_read_module_ref + else module_ref_value + if state == "active" or configuration_view == "runtime_applied" + else "" + ) + if indexed_module_ref and state != "both": + indexed_state = ( + {"source": "saved_state", "activation_state": "not_activated"} + if str(indexed_module_ref).startswith(("ConfigSave:", "ConfigCASSave:")) + else {"source": "active", "activation_state": "active"} + ) + indexed_result = code_read_indexed_fragment( + payload, + base_id=base_id_or_error, + module_ref=indexed_module_ref, + include_text=bool(include_text), + current_state=indexed_state, + ) + if indexed_result is not None: + if truthy(include_line_numbers): + selection = indexed_result.get("selection") if isinstance(indexed_result.get("selection"), dict) else {} + if selection.get("line_start") is not None: + indexed_result["line"] = int(selection.get("line_start") or 1) + indexed_result["column"] = 1 + return attach_effective_routine_chain( + annotate_configuration_view(indexed_result, configuration_view), + payload, + view=configuration_view, + ) is_saved_state_common_form_request = canonical_kind(str(payload.get("object_type") or payload.get("kind") or "")) == "CommonForm" and bool( payload.get("object_name") or payload.get("name") or payload.get("object_guid") or payload.get("guid") ) saved_state_result = None if state in {"working", "save", "both"}: - if saved_state_module_ref: + if saved_read_module_ref: saved_read_payload = { **payload, "base_id": base_id_or_error, + "module_ref": saved_read_module_ref, + "module_id": saved_read_module_ref, "include_storage": False, "include_text": bool(include_text), "max_chars": max_chars, @@ -25157,11 +32553,15 @@ 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) + if state == "working" and isinstance(saved_state_result, dict) and saved_state_result.get("status") == "source_missing": + saved_table, saved_file_name, saved_stream = parse_module_id(saved_read_module_ref) active_table = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"}.get(saved_table, "") - 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_module_ref = module_ref_value if mapped_saved_module_ref else ( + f"{active_table}:{saved_file_name}" + (f"#stream:{saved_stream}" if saved_stream is not None else "") + if active_table and saved_file_name + else "" + ) + if active_module_ref: active_fallback = read_module({ **saved_read_payload, "module_ref": active_module_ref, @@ -25175,6 +32575,17 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: **(active_fallback.get("diagnostics") if isinstance(active_fallback.get("diagnostics"), dict) else {}), "message": "Saved working copy was absent; the effective view fell back to the corresponding active module.", } + # A public object/module selector may resolve its active stream + # only after this branch. In that first-write case there is no + # saved counterpart to probe; defer to the active public route + # below instead of returning its expected source_missing result. + if ( + state == "working" + and isinstance(saved_state_result, dict) + and saved_state_result.get("status") == "source_missing" + and not module_ref_value + ): + saved_state_result = None if isinstance(saved_state_result, dict): saved_state_result = dict(saved_state_result) saved_state_result["schema"] = "onec_code_read.v1" @@ -25200,7 +32611,7 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: payload, view=configuration_view, ) - if state == "save" and (is_saved_state_common_form_request or saved_state_module_ref): + if state == "save" and (is_saved_state_common_form_request or saved_read_module_ref): return annotate_configuration_view({ "schema": "onec_code_read.v1", "method": "code.read", @@ -25217,36 +32628,75 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: "preview": truthy(payload.get("preview")), } read_payload.update(payload) + # A module_ref obtained from code.search is already an exact SQL target. + # Runtime reads must not spend the request budget rediscovering its owner + # through all extension definitions. Callers that need presentation data + # can opt in explicitly with resolve_owner=true. + if ( + module_ref_value + and configuration_view == "runtime_applied" + and "resolve_owner" not in payload + and not (object_selector.get("kind") or object_selector.get("guid") or object_selector.get("name")) + ): + read_payload["resolve_owner"] = False if state == "both": read_payload["state"] = "active" if saved_state_module_ref: - read_payload.pop("module_ref", None) - read_payload.pop("module_id", None) + active_counterpart_ref = code_read_active_counterpart_module_ref(module_ref_value) + if active_counterpart_ref: + read_payload["module_ref"] = active_counterpart_ref + read_payload["module_id"] = active_counterpart_ref + else: + read_payload.pop("module_ref", None) + read_payload.pop("module_id", None) read_payload.pop("include_line_numbers", None) if "query" in read_payload: read_payload.pop("query") read_payload.update(object_selector) - 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 annotate_configuration_view(code_read_both_response( - payload, - base_id=base_id_or_error, - saved_result=saved_state_result, - active_result={ - "schema": "onec_code_read.v1", - "method": "code.read", - "status": "not_found", - "error": "active_selector_required", - "current_state": {"source": "active", "activation_state": "active"}, - "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" result = read_module(read_payload) + if state == "both" and saved_state_module_ref and result.get("status") not in {"ok", "summary", "text"}: + logical_selector = code_read_active_counterpart_selector( + saved_state_result, + payload, + module_ref=module_ref_value, + ) + if logical_selector: + logical_payload = dict(read_payload) + logical_payload.pop("module_ref", None) + logical_payload.pop("module_id", None) + logical_payload.update(logical_selector) + logical_result = read_module(logical_payload) + if logical_result.get("status") in {"ok", "summary", "text"}: + result = dict(logical_result) + result["counterpart_resolution"] = { + "status": "resolved", + "strategy": "logical_owner_and_module_role", + "selector": { + key: logical_selector.get(key) + for key in ("kind", "name", "guid", "extension_guid", "module", "module_ordinal") + if logical_selector.get(key) not in {None, ""} + }, + } + else: + result = dict(result) + result["counterpart_resolution"] = { + "status": "not_found", + "strategy": "direct_module_ref_then_logical_owner", + "logical_status": logical_result.get("status"), + "logical_error": logical_result.get("error"), + } + else: + result = dict(result) + result["counterpart_resolution"] = { + "status": "not_resolved", + "strategy": "direct_module_ref", + "diagnostics": { + "message": "The direct active counterpart was absent and the saved module did not expose an unambiguous logical owner selector.", + }, + } if result.get("status") not in {"ok", "summary", "text"}: result = dict(result) result["method"] = "code.read" @@ -25288,12 +32738,44 @@ def code_read(payload: dict[str, Any]) -> dict[str, Any]: line, column = _snippet_to_line_column(str(result.get("text") or ""), int(result.get("text_range", {}).get("offset"))) result["line"] = line result["column"] = column - result["resolved_owner"] = { - "kind": object_selector.get("kind"), - "guid": object_selector.get("guid"), - "name": object_selector.get("name"), + result_owner = result.get("owner") if isinstance(result.get("owner"), dict) else {} + result_origin = result.get("origin") if isinstance(result.get("origin"), dict) else {} + result_extension = result_origin.get("extension") if isinstance(result_origin.get("extension"), dict) else {} + public_owner = { + "kind": result_owner.get("kind") or object_selector.get("kind"), + "guid": result_owner.get("guid") or object_selector.get("guid"), + "name": result_owner.get("name") or object_selector.get("name"), } - result = enrich_code_read_logical_owner(result, payload, base_id=base_id_or_error) + result["resolved_owner"] = public_owner + if not (module_ref_value and configuration_view == "runtime_applied" and read_payload.get("resolve_owner") is False): + result = enrich_code_read_logical_owner(result, payload, base_id=base_id_or_error) + result_module = result.get("module") if isinstance(result.get("module"), dict) else {} + result_routine = str((result.get("selection") or {}).get("routine_name") or payload.get("routine_name") or "").strip() + if ( + public_owner.get("kind") + and public_owner.get("name") + and result_extension.get("name") + and module_ref_value + ): + canonical_path = ( + ".".join([RU_KIND.get(str(public_owner["kind"]), str(public_owner["kind"])), str(public_owner["name"]), result_routine]) + if result_routine + else None + ) + result["write_selector"] = { + "target": { + "kind": "module", + "extension": result_extension.get("name"), + "ref": object_selector_ref(public_owner.get("kind"), public_owner.get("name")), + "module": result_module.get("kind") or "bsl_module", + "module_ref": module_ref_value, + "concrete_reference": module_ref_value, + "layer_id": f"extension:{result_extension.get('guid')}" if result_extension.get("guid") else None, + **({"module_ordinal": result_module.get("module_ordinal")} if result_module.get("module_ordinal") is not None else {}), + **({"routine_name": result_routine} if result_routine else {}), + **({"canonical_path": canonical_path} if canonical_path else {}), + } + } result["resolved_selector"] = { "base_id": base_id_or_error, **({ "kind": object_selector.get("kind")} if object_selector.get("kind") else {}), @@ -25317,6 +32799,200 @@ def _code_binding_extract_placeholders(text: str) -> list[str]: return [value.strip() for value in matches if str(value or "").strip()] +def _template_bsl_usage_trace(text: str, template_name: str) -> dict[str, Any]: + """Extract conservative print-template area and parameter links from one BSL module. + + This deliberately describes only direct, static BSL assignments. It must not + pretend that a variable area name, a helper call, or ``Параметры.Заполнить`` + has been resolved to individual template cells. + """ + if not isinstance(text, str) or not str(template_name or "").strip(): + return {"status": "not_found", "template_retrievals": [], "areas": []} + lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") + # BSL frequently wraps a calculation over several lines. Work in complete + # semicolon-terminated statements, while retaining the original first line + # for a stable navigation target. + statements: list[tuple[int, str]] = [] + statement_start = 0 + statement_parts: list[str] = [] + for line_number, line in enumerate(lines, 1): + stripped = line.strip() + if not statement_parts and (not stripped or stripped.startswith("//")): + continue + if not statement_parts: + statement_start = line_number + statement_parts.append(stripped) + if ";" in stripped: + statements.append((statement_start, " ".join(part for part in statement_parts if part))) + statement_parts = [] + escaped_template = re.escape(str(template_name).strip()) + retrieval_re = re.compile( + rf'^\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*=\s*' + rf'(?:[A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*\.)*' + rf'(?:МакетПечатнойФормы|ПолучитьМакет)\s*\(\s*"[^"]*{escaped_template}[^"]*"\s*\)', + re.IGNORECASE, + ) + area_re = re.compile( + r'^\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*=\s*' + r'([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.ПолучитьОбласть\s*\(\s*"([^"]+)"\s*\)', + re.IGNORECASE, + ) + parameter_re = re.compile( + r'^\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.Параметры\.' + r'([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*=\s*(.+?)\s*;?\s*$', + re.IGNORECASE, + ) + fill_re = re.compile( + r'^\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.Параметры\.Заполнить\s*\(\s*(.+?)\s*\)\s*;?\s*$', + re.IGNORECASE, + ) + template_variables: set[str] = set() + retrievals: list[dict[str, Any]] = [] + for line_number, line in statements: + match = retrieval_re.match(line) + if match: + variable = match.group(1) + template_variables.add(normalize(variable)) + retrievals.append({"variable": variable, "line": line_number, "expression": line.strip()}) + areas_by_variable: dict[str, dict[str, Any]] = {} + for line_number, line in statements: + match = area_re.match(line) + if not match or normalize(match.group(2)) not in template_variables: + continue + area = { + "name": match.group(3), "variable": match.group(1), "line": line_number, + "parameters": [], "bulk_fill_sources": [], + } + areas_by_variable[normalize(match.group(1))] = area + local_assignment_re = re.compile( + r'^\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*=\s*(.+?)\s*;\s*$', re.IGNORECASE, + ) + member_assignment_re = re.compile( + r'^\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*=\s*(.+?)\s*;\s*$', + re.IGNORECASE, + ) + local_assignments: dict[str, list[dict[str, Any]]] = {} + member_assignments: dict[tuple[str, str], list[dict[str, Any]]] = {} + for line_number, line in statements: + local_match = local_assignment_re.match(line) + if local_match: + local_assignments.setdefault(normalize(local_match.group(1)), []).append({ + "line": line_number, "expression": local_match.group(2).strip(), + }) + member_match = member_assignment_re.match(line) + if member_match: + member_assignments.setdefault((normalize(member_match.group(1)), normalize(member_match.group(2))), []).append({ + "line": line_number, "expression": member_match.group(3).strip(), + }) + + def resolve_local_expression(expression: str, before_line: int) -> dict[str, Any] | None: + # Do not infer arbitrary code semantics. Resolve only a bare local + # variable to its last preceding assignment in this static module text. + if not re.fullmatch(r"[A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*", expression or ""): + return None + candidates = [item for item in local_assignments.get(normalize(expression), []) if item["line"] < before_line] + if not candidates: + return None + item = candidates[-1] + return {"variable": expression, "expression": item["expression"], "line": item["line"], "resolution": "local_static_assignment"} + + def resolve_wrapped_local_expression(expression: str, before_line: int) -> dict[str, Any] | None: + # Formatting/value-conversion wrappers preserve a visible first input. + # For any other call the adapter leaves the expression opaque rather than + # inventing an interprocedural data lineage. + match = re.match( + r'^\s*(Формат|Строка|Окр|ЦЕЛ)\s*\(\s*([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)(?:\s*,|\s*\))', + expression or "", re.IGNORECASE, + ) + if not match: + return None + resolved = resolve_local_expression(match.group(2), before_line) + if resolved is None: + return None + return {"wrapper": match.group(1), "input": resolved, "resolution": "wrapped_local_static_assignment"} + + def resolve_member_expression(expression: str, before_line: int) -> dict[str, Any] | None: + match = re.fullmatch(r"([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)", expression or "") + if not match: + return None + variable, field = match.group(1), match.group(2) + candidates = [ + item for item in member_assignments.get((normalize(variable), normalize(field)), []) + if item["line"] < before_line + ] + if not candidates: + return None + item = candidates[-1] + source_fields = [] + for source_variable, source_field in re.findall( + r"\b([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)", item["expression"] + ): + if normalize(source_variable) == normalize(variable) and normalize(source_field) == normalize(field): + continue + source_fields.append({"expression": f"{source_variable}.{source_field}", "variable": source_variable, "field": source_field}) + return { + "member": expression, "expression": item["expression"], "line": item["line"], + "resolution": "member_static_assignment", + **({"source_fields": source_fields} if source_fields else {}), + **({"aggregation": "accumulation"} if re.search(rf"\b{re.escape(variable)}\.{re.escape(field)}\s*\+", item["expression"], re.IGNORECASE) else {}), + } + + for line_number, line in statements: + parameter_match = parameter_re.match(line) + if parameter_match: + area = areas_by_variable.get(normalize(parameter_match.group(1))) + if area is not None: + parameter = { + "name": parameter_match.group(2), "expression": parameter_match.group(3).rstrip(";").strip(), + "line": line_number, "resolution": "static_assignment", + } + local_expression = resolve_local_expression(parameter["expression"], line_number) + if local_expression: + parameter["expression_lineage"] = local_expression + else: + wrapped_expression = resolve_wrapped_local_expression(parameter["expression"], line_number) + if wrapped_expression: + parameter["expression_lineage"] = wrapped_expression + else: + member_expression = resolve_member_expression(parameter["expression"], line_number) + if member_expression: + parameter["expression_lineage"] = member_expression + area["parameters"].append(parameter) + continue + fill_match = fill_re.match(line) + if fill_match: + area = areas_by_variable.get(normalize(fill_match.group(1))) + if area is not None: + area["bulk_fill_sources"].append({ + "expression": fill_match.group(2).rstrip(";").strip(), "line": line_number, + "resolution": "bulk_fill_unexpanded", + }) + areas = list(areas_by_variable.values()) + report_calls: list[dict[str, Any]] = [] + seen_report_calls: set[tuple[str, str, int]] = set() + report_call_re = re.compile( + r"Отчеты\.([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\.([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*\(", + re.IGNORECASE, + ) + for line_number, statement in statements: + for call_match in report_call_re.finditer(statement): + key = (normalize(call_match.group(1)), normalize(call_match.group(2)), line_number) + if key in seen_report_calls: + continue + seen_report_calls.add(key) + report_calls.append({ + "report": f"Report.{call_match.group(1)}", "method": call_match.group(2), "line": line_number, + "resolution": "static_external_report_call", + }) + return { + "status": "confirmed" if retrievals else "not_found", + "template_retrievals": retrievals, + "areas": areas, + "external_report_calls": report_calls, + "diagnostics": {"scope": "direct_static_bsl", "unexpanded_bulk_fill": any(area["bulk_fill_sources"] for area in areas)}, + } + + def templates_bindings(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "templates.bindings") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": @@ -25351,21 +33027,115 @@ def templates_bindings(payload: dict[str, Any]) -> dict[str, Any]: "bindings": [], } bindings: list[dict[str, Any]] = [] + index_refresh_job: dict[str, Any] | None = None for template in templates_result.get("templates") or []: if not isinstance(template, dict): continue source_text = template.get("text") or template.get("text_preview") or "" placeholders = _code_binding_extract_placeholders(str(source_text)) + template_kind = str(template.get("kind") or "").strip() binding_item = { "template": { "name": template.get("name"), "presentation": template.get("presentation") or template.get("name"), "file_name": template.get("file_name"), + **({"kind": template_kind} if template_kind else {}), }, "bindings": sorted(set(placeholders)), "binding_count": len(set(placeholders)), - "format": template.get("format") or template.get("kind"), + "format": template_kind if template_kind == "data_composition_schema" else (template.get("format") or template_kind), } + template_name = str(template.get("name") or "").strip() + if template_name: + indexed_usage = metadata_code_index_search({ + "base_id": base_id_or_error, **object_selector, "query": template_name, + "limit": 30, "scan_limit": 200, "strict": True, "verify": True, + }) + indexed_matches = indexed_usage.get("matches") if indexed_usage.get("status") == "ok" and isinstance(indexed_usage.get("matches"), list) else [] + if indexed_matches: + usage = indexed_usage + usage_matches = indexed_matches + usage_source = "code_index_live_sha1_verified" + else: + usage = search_modules({ + "base_id": base_id_or_error, **object_selector, "query": template_name, + "state": "working", "limit": 30, "scan_limit": 200, + "resolve_owners": True, + }) + usage_matches = usage.get("matches") if isinstance(usage.get("matches"), list) else [] + usage_source = "live_sql_fallback" + # Do not make the current answer wait for index maintenance. + # It was already produced from live SQL. A bounded object-only + # job makes the next identical lookup eligible for the verified + # fast path without ever treating the stale index as evidence. + if usage_matches and index_refresh_job is None: + index_refresh_job = adapter_start_job({ + "method": "metadata.code_index.build", + "payload": { + "base_id": base_id_or_error, **object_selector, + "max_items": 200, "scan_limit": 1000, "include_vectors": False, + }, + "timeout_seconds": 60, + }) + code_usage: dict[str, Any] = { + "status": "confirmed" if usage_matches else "not_found", + "source": usage_source, + **({"index_refresh": {key: index_refresh_job.get(key) for key in ("status", "job_id", "method") if index_refresh_job.get(key) is not None}} if usage_source == "live_sql_fallback" and index_refresh_job else {}), + "matches": [ + { + key: match.get(key) + for key in ("owner", "module", "routine", "line", "preview", "snippet", "read_selector") + if match.get(key) is not None and match.get(key) != "" + } + for match in usage_matches[:30] if isinstance(match, dict) + ], + **({"diagnostics": usage.get("diagnostics")} if usage.get("diagnostics") else {}), + } + # A text hit by itself is not enough for a print-form lineage. Read + # only the already object-scoped modules and expose direct static + # Макет -> Область -> Параметры links. The bounded read count keeps + # this metadata call from becoming a global code scan. + trace_cache: dict[str, dict[str, Any]] = {} + traces: list[dict[str, Any]] = [] + trace_errors: list[dict[str, Any]] = [] + for match in usage_matches[:12]: + if not isinstance(match, dict): + continue + selector = match.get("read_selector") + if not isinstance(selector, dict): + continue + read_payload = {key: value for key, value in selector.items() if key != "method"} + read_payload.update({ + "base_id": base_id_or_error, "configuration_view": "effective_working", + "preview": False, "max_chars": 250000, + }) + cache_key = json.dumps(read_payload, ensure_ascii=False, sort_keys=True, default=str) + read_result = trace_cache.get(cache_key) + if read_result is None: + read_result = code_read(read_payload) + trace_cache[cache_key] = read_result + if read_result.get("status") != "ok": + trace_errors.append({ + "owner": match.get("owner"), "module": match.get("module"), + "error": read_result.get("error") or read_result.get("status"), + }) + continue + trace = _template_bsl_usage_trace(str(read_result.get("text") or ""), template_name) + if trace.get("status") != "confirmed": + continue + traces.append({ + "owner": match.get("owner"), "module": match.get("module"), + "routine": match.get("routine"), "read_selector": selector, + **trace, + }) + if traces: + code_usage["static_traces"] = traces + if trace_errors: + code_usage["trace_diagnostics"] = { + "status": "partial", "unread_modules": trace_errors, + "message": "Some already matched modules could not be read for static template lineage.", + } + binding_item["code_usage"] = code_usage if bool(include_storage): binding_item["source"] = { "kind": "live_sql", @@ -25394,6 +33164,118 @@ def templates_bindings(payload: dict[str, Any]) -> dict[str, Any]: } +def template_field_lineage(payload: dict[str, Any]) -> dict[str, Any]: + """Return the small, evidence-backed part of a print-form parameter lineage.""" + normalized = normalize_object_selector_aliases(payload, "template.field.lineage") + if isinstance(normalized, dict) and normalized.get("status") == "invalid_argument": + return normalized + template_name = str(normalized.get("template") or "").strip() + field_name = str(normalized.get("field") or "").strip() + area_name = str(normalized.get("area") or "").strip() + resolve_scd = truthy(normalized.get("resolve_scd")) + result = templates_bindings({**normalized, "template": template_name}) + base_result = { + "schema": "onec_template_field_lineage.v1", "method": "template.field.lineage", + "base_id": normalized.get("base_id"), "query": { + "template": template_name, "field": field_name, + **({"area": area_name} if area_name else {}), + }, "object": result.get("object"), + } + if result.get("status") != "ok": + return {**base_result, "status": result.get("status") or "error", "error": result.get("error"), "lineage": [], "diagnostics": result.get("diagnostics", {})} + # A DataCompositionSchema is itself the field producer. Do not require a + # BSL assignment such as ``Area.Parameters``: reports normally hand this + # template to the platform, so the correct static route is straight into + # the SQL-decoded SCD field graph. + dcs_template = next( + ( + binding for binding in result.get("bindings") or [] + if isinstance(binding, dict) + and normalize(str((binding.get("template") or {}).get("name") or "")) == normalize(template_name) + and str((binding.get("template") or {}).get("kind") or "") == "data_composition_schema" + ), + None, + ) + object_card = result.get("object") if isinstance(result.get("object"), dict) else {} + if resolve_scd and dcs_template and canonical_kind(str(object_card.get("kind") or "")) == "Report": + dcs_result = dcs_field_lineage({ + "base_id": normalized.get("base_id"), "report": object_card.get("name"), + "schema": template_name, "field": field_name, + **({"extension": normalized.get("extension")} if normalized.get("extension") not in {None, ""} else {}), + **({"extension_guid": normalized.get("extension_guid")} if normalized.get("extension_guid") not in {None, ""} else {}), + }) + confirmed_sources = (dcs_result.get("agent_summary") or {}).get("confirmed_sources") or [] + direct_status = "confirmed" if confirmed_sources else ("partial" if dcs_result.get("status") == "ok" else dcs_result.get("status") or "not_found") + direct_lineage = [{ + "template": template_name, "template_kind": "data_composition_schema", "field": field_name, + "resolution": "direct_scd_field_lineage", "dcs_field_lineage": dcs_result, + }] + return { + **base_result, "status": direct_status, + **({"error": "not_found"} if direct_status == "not_found" else {}), + "lineage": direct_lineage, + "source_context_candidates": [{ + "report": object_selector_ref("Report", str(object_card.get("name") or "")), + "method": "dcs.field.lineage", "status": direct_status, + "resolution": "selected_data_composition_schema", + "confirmed_sources": confirmed_sources, + }], + "counts": {"matches": len(direct_lineage), "source_context_candidates": 1}, + "diagnostics": {"scope": "direct_sql_scd", "message": "Selected template is a decoded DataCompositionSchema; field lineage was resolved directly without a BSL assignment."}, + } + matches: list[dict[str, Any]] = [] + producer_candidates: list[dict[str, Any]] = [] + producer_seen: set[tuple[str, str, int]] = set() + trace_seen = False + for binding in result.get("bindings") or []: + if not isinstance(binding, dict) or normalize(str((binding.get("template") or {}).get("name") or "")) != normalize(template_name): + continue + usage = binding.get("code_usage") if isinstance(binding.get("code_usage"), dict) else {} + for trace in usage.get("static_traces") or []: + if not isinstance(trace, dict): + continue + trace_seen = True + for call in trace.get("external_report_calls") or []: + if not isinstance(call, dict) or "таблицаданных" not in normalize(str(call.get("method") or "")): + continue + candidate_key = (str(call.get("report") or ""), str(call.get("method") or ""), int(call.get("line") or 0)) + if candidate_key not in producer_seen: + producer_seen.add(candidate_key) + producer_candidates.append({**call, "status": "candidate", "reason": "Static report call in the same print module; field-to-dataset mapping still requires SCD analysis."}) + for area in trace.get("areas") or []: + if not isinstance(area, dict) or (area_name and normalize(str(area.get("name") or "")) != normalize(area_name)): + continue + for parameter in area.get("parameters") or []: + if not isinstance(parameter, dict) or normalize(str(parameter.get("name") or "")) != normalize(field_name): + continue + matches.append({ + "template": template_name, "area": {"name": area.get("name"), "variable": area.get("variable"), "line": area.get("line")}, + "parameter": parameter, + "code": {key: trace.get(key) for key in ("owner", "module", "routine", "read_selector") if trace.get(key) is not None}, + }) + status = "confirmed" if matches else ("partial" if trace_seen else "not_found") + diagnostics: dict[str, Any] = {"scope": "direct_static_bsl"} + if status == "partial": + diagnostics["message"] = "Template code was found, but the requested parameter is populated dynamically, through a helper, or in an unresolved area." + elif status == "not_found": + diagnostics["message"] = "No static BSL assignment was found for the requested template parameter." + if resolve_scd: + for candidate in producer_candidates[:1]: + report_ref = str(candidate.get("report") or "") + report_name = report_ref.split(".", 1)[1] if report_ref.startswith("Report.") else report_ref + dcs_result = dcs_field_lineage({"base_id": normalized.get("base_id"), "report": report_name, "field": field_name}) + candidate["dcs_field_lineage"] = dcs_result + candidate["status"] = "confirmed" if (dcs_result.get("agent_summary") or {}).get("confirmed_sources") else candidate["status"] + if not producer_candidates: + diagnostics["scd_resolution"] = "skipped_no_static_data_producer_candidate" + return { + **base_result, "status": status, **({"error": "not_found"} if status == "not_found" else {}), + "lineage": matches, "source_context_candidates": producer_candidates, + "counts": {"matches": len(matches), "source_context_candidates": len(producer_candidates)}, + "diagnostics": diagnostics, + } + + def metadata_extension_action_from_evidence( *, source: str, @@ -25517,7 +33399,7 @@ def metadata_resolve_overrides_write_plan_evidence( evidence["target"]["extension_action"] = extension_actions[0] evidence["next_resolution"]["params"]["layer"] = "extension_saved_state" action_class = metadata_write_plan_operation_class(str(extension_actions[0].get("operation_class") or "")) - if action_class not in {"", "unknown_extension_action", "base_definition"}: + if action_class not in {"", "unknown_extension_action", "base_definition", "extension_definition"}: evidence["intent"] = {"operation": action_class} elif extension_actions: evidence["next_resolution"]["params"]["layer"] = "extension_saved_state" @@ -25563,10 +33445,15 @@ def metadata_resolve_overrides(payload: dict[str, Any]) -> dict[str, Any]: "base_id": base_id_or_error, } extension_filter = str(payload.get("extension") or "").strip() - modules_result = metadata_object_modules(object_ref) + # Internal storage handles are required to read the discovered modules. + # They remain hidden from the public response below. + modules_result = metadata_object_modules({**object_ref, "include_storage": True}) if modules_result.get("status") != "ok" and not (extension_filter and state in {"working", "save", "both"}): return public_error_result(modules_result, include_storage=False, method="metadata.resolve_overrides") modules = [module for module in modules_result.get("modules") or [] if isinstance(module, dict)] if modules_result.get("status") == "ok" else [] + public_object = public_metadata_row(modules_result.get("object") or {}, include_storage=False) + public_object_origin = public_object.get("origin") if isinstance(public_object.get("origin"), dict) else {} + extension_owned_object = public_object_origin.get("source") == "extension" and not extension_filter chain: list[dict[str, Any]] = [] if extension_filter and state in {"working", "save", "both"}: extension_guid, extension_error = extension_filter_to_guid(base_id_or_error, extension_filter, method="metadata.resolve_overrides") @@ -25642,71 +33529,126 @@ def metadata_resolve_overrides(payload: dict[str, Any]) -> dict[str, Any]: elif extension_error: return extension_error if state == "save": - modules = [] + # Object-scoped saved counterparts are resolved below. The explicit + # extension scan above remains a fallback for callers that supplied + # only an extension selector. + modules = modules if modules_result.get("status") == "ok" else [] + resolved_module_ordinals: set[int] = set() for ordinal, module in enumerate(modules, start=1): module_id = str(module.get("module_id") or "") if not module_id: continue - module_read_payload = { - "base_id": base_id_or_error, - "module_id": module_id, - "routine_name": method_name, - "include_storage": False, - "max_chars": 100000, - "include_text": True, - "preview": True, - } - read_result = read_module(module_read_payload) - if read_result.get("status") not in {"ok", "summary"}: - continue - selection = read_result.get("selection") or {} - if selection.get("status") == "not_found": - continue - source = "configuration" - if module_id.startswith("ConfigCAS:"): - source = "extension" - extension_action = metadata_extension_action_from_evidence( - source=source, - method_name=method_name, - read_result=read_result, - module=module, - ) - chain.append( - { - "order": len(chain) + 1, - "mechanism": "routine", - "method": method_name, - "source": source, - "extension_action": extension_action, - "line_start": selection.get("line_start"), - "line_end": selection.get("line_end"), - "match_by": selection.get("match_by"), - "module": { - "module_ordinal": ordinal, - "name": public_module_row( - module, - include_storage=False, - ordinal=ordinal, - owner_kind=object_ref.get("kind"), - owner_name=object_ref.get("name"), - ).get("name"), - }, - "read_selector": { + module_table, _module_file_name, _module_stream_index = parse_module_id(module_id) + if module_table in {"Config", "ConfigCAS"}: + saved_module_ref = code_read_saved_counterpart_module_ref(base_id_or_error, module_id, payload) + if state == "active": + candidate_module_refs = [module_id] + elif state == "save": + candidate_module_refs = [saved_module_ref] if saved_module_ref else [] + elif state == "both": + candidate_module_refs = [ref for ref in (saved_module_ref, module_id) if ref] + else: + candidate_module_refs = [ref for ref in (saved_module_ref, module_id) if ref] + elif module_table in {"ConfigSave", "ConfigCASSave"}: + active_module_ref = code_read_active_counterpart_module_ref(module_id) + if state == "active": + candidate_module_refs = [active_module_ref] if active_module_ref else [] + elif state == "save": + candidate_module_refs = [module_id] + elif state == "both": + candidate_module_refs = [ref for ref in (module_id, active_module_ref) if ref] + else: + candidate_module_refs = [ref for ref in (module_id, active_module_ref) if ref] + else: + candidate_module_refs = [module_id] + selected_reads: list[tuple[str, dict[str, Any], dict[str, Any]]] = [] + for candidate_module_ref in dict.fromkeys(ref for ref in candidate_module_refs if ref): + read_result = read_module( + { "base_id": base_id_or_error, - "module_id": module_id, + "module_ref": candidate_module_ref, "routine_name": method_name, - "preview": True, + "include_storage": False, "max_chars": 100000, - }, - } - ) + "include_text": True, + "preview": True, + } + ) + if read_result.get("status") not in {"ok", "summary"}: + continue + selection = read_result.get("selection") or {} + if selection.get("status") == "not_found": + continue + selected_reads.append((candidate_module_ref, read_result, selection)) + if state != "both": + break + if not selected_reads: + continue + for selected_module_ref, read_result, selection in selected_reads: + selected_table, _selected_file_name, _selected_stream_index = parse_module_id(selected_module_ref) + source = "extension" if selected_table in {"ConfigCAS", "ConfigCASSave"} else "configuration" + activation_state = "saved_state" if selected_table in {"ConfigSave", "ConfigCASSave"} else "active" + extension_action = metadata_extension_action_from_evidence( + source=source, + method_name=method_name, + read_result=read_result, + module={**module, "module_ref": selected_module_ref}, + ) + if ( + source == "extension" + and extension_owned_object + and extension_action.get("status") == "unknown" + and extension_action.get("operation_class") == "unknown_extension_action" + ): + extension_action = { + "status": "ok", + "source": "extension", + "routine": method_name, + "operation_class": "extension_definition", + "requires_control_fragment": False, + "diagnostics": { + "message": "The selected metadata object is defined by the extension and the routine has no interception annotation; it is an ordinary extension-owned routine, not an unresolved base override." + }, + } + chain.append( + { + "order": len(chain) + 1, + "mechanism": "routine", + "method": method_name, + "source": source, + "activation_state": activation_state, + "extension_action": extension_action, + "line_start": selection.get("line_start"), + "line_end": selection.get("line_end"), + "match_by": selection.get("match_by"), + "module": { + "module_ordinal": ordinal, + "name": public_module_row( + module, + include_storage=False, + ordinal=ordinal, + owner_kind=object_ref.get("kind"), + owner_name=object_ref.get("name"), + ).get("name"), + **({"form": (module.get("form") or {}).get("name")} if isinstance(module.get("form"), dict) else {}), + }, + "read_selector": { + "base_id": base_id_or_error, + "module_ref": selected_module_ref, + "routine_name": method_name, + "preview": True, + "max_chars": 100000, + }, + } + ) + resolved_module_ordinals.add(ordinal) return { "schema": "onec_metadata_resolve_overrides.v1", "status": "ok" if chain else "not_found", **({"error": "not_found"} if not chain else {}), "base_id": base_id_or_error, "method": "metadata.resolve_overrides", - "object": (modules_result.get("object") or {}), + "object": public_object, "state": state, "target_method": method_name, "chain": chain, @@ -25718,16 +33660,50 @@ def metadata_resolve_overrides(payload: dict[str, Any]) -> dict[str, Any]: "write_plan_evidence": metadata_resolve_overrides_write_plan_evidence( base_id=base_id_or_error, method_name=method_name, - object_payload=modules_result.get("object") if isinstance(modules_result.get("object"), dict) else {}, + object_payload=public_object, chain=chain, ), - "counts": {"steps": len(chain), "resolved": len(chain), "not_found": int(len(modules) - len(chain)) if modules else 0}, + "counts": { + "steps": len(chain), + "resolved": len(resolved_module_ordinals), + "not_found": max(0, int(len(modules) - len(resolved_module_ordinals))) if modules else 0, + }, "diagnostics": { "message": "Chain is built from module text scan of discovered object modules. Extension names are inferred from module storage prefix; точное определение расширения требует metadata.module_owner_cache." } if chain else {"message": "Переопределения не найдены в доступных модулях объекта."}, } +_STATIC_CALL_KEYWORDS = { + "если", "иначеесли", "пока", "для", "каждого", "новый", "выполнить", "процедура", "функция", "возврат", "попытка", "исключение", + "if", "elseif", "while", "for", "each", "new", "return", "try", "except", +} + + +def diagnostics_static_calls(text: str, *, line_offset: int = 0) -> list[dict[str, Any]]: + """Extract conservative same-owner and CommonModule.Routine BSL calls.""" + pattern = re.compile(r"(? dict[str, Any]: payload = normalize_object_selector_aliases(payload, "diagnostics.call_chain") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": @@ -25738,55 +33714,185 @@ def diagnostics_call_chain(payload: dict[str, Any]) -> dict[str, Any]: method_name = str(payload.get("entry_method") or payload.get("method_name") or "").strip() if not method_name: return invalid_argument("diagnostics.call_chain", "entry_method", "entry_method is required.") + module_ref = str(payload.get("module_ref") or "").strip() object_selector, _ = _code_query_object_selector(payload) - if not object_selector.get("kind") and not object_selector.get("guid") and not object_selector.get("name"): + if not module_ref and not object_selector.get("kind") and not object_selector.get("guid") and not object_selector.get("name"): return invalid_argument("diagnostics.call_chain", "selector", OBJECT_SELECTOR_REQUIRED_MESSAGE_NO_ORDINAL) - override = metadata_resolve_overrides( - { - "base_id": base_id_or_error, - "method_name": method_name, - "object_type": object_selector.get("kind"), - "object_name": object_selector.get("name"), - "object_guid": object_selector.get("guid"), + depth_value, depth_error = parse_int_argument(payload, "depth", method="diagnostics.call_chain", default=1, minimum=0, maximum=6) + if depth_error: + return depth_error + max_nodes_value, max_nodes_error = parse_int_argument(payload, "max_nodes", method="diagnostics.call_chain", default=40, minimum=1, maximum=200) + if max_nodes_error: + return max_nodes_error + timeout_value, timeout_error = parse_int_argument(payload, "timeout_seconds", method="diagnostics.call_chain", default=30, minimum=1, maximum=120) + if timeout_error: + return timeout_error + depth = int(depth_value or 1) + max_nodes = int(max_nodes_value or 40) + timeout_seconds = int(timeout_value or 30) + entry_owner = { + "kind": object_selector.get("kind"), "name": object_selector.get("name"), "guid": object_selector.get("guid"), + "ref": object_selector_ref(object_selector.get("kind"), object_selector.get("name")), + } + + def read_routine(owner: dict[str, Any], routine: str) -> dict[str, Any]: + direct_module_ref = str(owner.get("_module_ref") or "").strip() + if direct_module_ref: + return read_module({ + "base_id": base_id_or_error, "module_ref": direct_module_ref, + "routine_name": routine, "include_text": True, "max_chars": 100000, + "timeout_seconds": timeout_seconds, + }) + return code_read({ + "base_id": base_id_or_error, "routine_name": routine, "include_text": True, "max_chars": 100000, + "include_routines": False, "include_summary": False, "timeout_seconds": timeout_seconds, + "configuration_view": "effective_working", + **({"ref": owner.get("ref")} if owner.get("ref") else {}), + **({"kind": owner.get("kind")} if owner.get("kind") and not owner.get("ref") else {}), + **({"name": owner.get("name")} if owner.get("name") and not owner.get("ref") else {}), + **({"guid": owner.get("guid")} if owner.get("guid") and not owner.get("ref") else {}), + }) + + if module_ref: + entry_read = read_module({ + "base_id": base_id_or_error, "module_ref": module_ref, "routine_name": method_name, + "include_text": True, "max_chars": 100000, "timeout_seconds": timeout_seconds, + }) + else: + entry_read = read_routine(entry_owner, method_name) + # A form-module reference is already an unambiguous entry point, but its + # public owner is discovered only while decoding the form payload. Reuse + # that owner for subsequent calls instead of searching the whole base. + if module_ref: + discovered_owner = entry_read.get("owner") if isinstance(entry_read.get("owner"), dict) else {} + if discovered_owner: + entry_owner = { + "kind": discovered_owner.get("kind") or entry_owner.get("kind"), + "name": discovered_owner.get("name") or entry_owner.get("name"), + "guid": discovered_owner.get("guid") or entry_owner.get("guid"), + "ref": discovered_owner.get("ref") or object_selector_ref( + discovered_owner.get("kind") or entry_owner.get("kind"), + discovered_owner.get("name") or entry_owner.get("name"), + ), + "_module_ref": module_ref, + } + entry_selection = entry_read.get("selection") if isinstance(entry_read.get("selection"), dict) else {} + if entry_read.get("status") not in {"ok", "summary", "text"} or entry_selection.get("status") == "not_found": + return { + "schema": "onec_diagnostics_call_chain.v2", "status": "not_found", "error": "entry_routine_not_found", + "base_id": base_id_or_error, "entry": {"method": method_name, "object": entry_owner}, + "diagnostics": {"message": "Entry routine was not found through the supplied public selector."}, } - ) + # For a direct module entry point the owner is resolved from the decoded + # module, rather than supplied by the caller. Feed that public owner into + # override analysis as well. + override = metadata_resolve_overrides({ + "base_id": base_id_or_error, + "method_name": method_name, + "object_type": entry_owner.get("kind"), + "object_name": entry_owner.get("name"), + "object_guid": entry_owner.get("guid"), + }) if entry_owner.get("kind") or entry_owner.get("name") or entry_owner.get("guid") else {"status": "not_found"} if override.get("status") not in {"ok", "not_found"}: return override - usage = code_search( - { - "base_id": base_id_or_error, - "query": method_name, - "object_type": object_selector.get("kind"), - "object_name": object_selector.get("name"), - "object_guid": object_selector.get("guid"), - "limit": 200, - "include_context": False, - "include_storage": False, - } - ) - call_nodes = override.get("chain") if isinstance(override.get("chain"), list) else [] + nodes: list[dict[str, Any]] = [{ + "id": "n1", "depth": 0, "owner": {key: value for key, value in entry_owner.items() if not key.startswith("_")}, + "routine_name": entry_selection.get("routine_name") or method_name, + "line_start": entry_selection.get("line_start"), "line_end": entry_selection.get("line_end"), + "read_selector": {"method": "code.read", "base_id": base_id_or_error, **({"ref": entry_owner.get("ref")} if entry_owner.get("ref") else {}), "routine_name": entry_selection.get("routine_name") or method_name}, + }] + edges: list[dict[str, Any]] = [] + unresolved_calls: list[dict[str, Any]] = [] + queue: list[tuple[str, int, dict[str, Any], dict[str, Any]]] = [("n1", 0, entry_owner, entry_read)] + seen = {"|".join([str(entry_owner.get("kind") or ""), str(entry_owner.get("name") or ""), str(entry_selection.get("routine_name") or method_name).casefold()])} + while queue and len(nodes) < max_nodes: + source_id, current_depth, owner, source_read = queue.pop(0) + if current_depth >= depth: + continue + source_selection = source_read.get("selection") if isinstance(source_read.get("selection"), dict) else {} + line_offset = max(0, int(source_selection.get("line_start") or 1) - 1) + source_text = str(source_read.get("text") or "") + # Managed-form code frequently calls an object method through a local + # variable obtained with РеквизитФормыВЗначение(... Тип("ДокументОбъект.X")) . + # This is a static, SQL-readable type declaration, so it is safe to + # use it as a bounded owner transition in the graph. + typed_object_variables: dict[str, dict[str, Any]] = {} + for variable_match in re.finditer( + r'(?im)^\s*([\wА-Яа-яЁё]+)\s*=.*?Тип\s*\(\s*"(ДокументОбъект|СправочникОбъект|ОбработкаОбъект|ОтчетОбъект)\.([^"\r\n]+)"\s*\)', + source_text, + ): + kind_by_runtime_type = { + "ДокументОбъект": "Document", "СправочникОбъект": "Catalog", + "ОбработкаОбъект": "DataProcessor", "ОтчетОбъект": "Report", + } + typed_kind = kind_by_runtime_type.get(variable_match.group(2)) + if typed_kind: + typed_name = variable_match.group(3).strip() + typed_object_variables[variable_match.group(1).casefold()] = { + "kind": typed_kind, "name": typed_name, + "ref": object_selector_ref(typed_kind, typed_name), + } + calls = diagnostics_static_calls(source_text, line_offset=line_offset) + # Resolve statically typed object calls before generic platform calls; + # it keeps an object-scoped trace bounded and avoids pointless reads of + # built-in 1C functions. + calls.sort(key=lambda item: 0 if str(item.get("module_name") or "").casefold() in typed_object_variables else 1) + for call in calls: + if call.get("resolution") == "unresolved_qualified_expression": + unresolved_calls.append({"from": source_id, **call, "reason": "Only CommonModule.Routine and same-owner calls are resolved automatically."}) + continue + if not call.get("module_name") and str(call.get("routine_name") or "").casefold() in { + "реквизитформывзначение", "значениевреквизитформы", "тип", + }: + unresolved_calls.append({"from": source_id, **call, "reason": "1C platform built-in; it has no configuration module source."}) + continue + module_name = str(call.get("module_name") or "") + typed_owner = typed_object_variables.get(module_name.casefold()) if module_name else None + target_owner = typed_owner or (owner if not module_name else {"kind": "CommonModule", "name": module_name, "ref": object_selector_ref("CommonModule", module_name)}) + if typed_owner: + call = {**call, "resolution": "typed_object_variable"} + key = "|".join([str(target_owner.get("kind") or ""), str(target_owner.get("name") or ""), str(call.get("routine_name") or "").casefold()]) + if key in seen: + edges.append({"from": source_id, "to": None, **call, "status": "cycle_or_duplicate"}) + continue + target_read = read_routine(target_owner, str(call.get("routine_name") or "")) + target_selection = target_read.get("selection") if isinstance(target_read.get("selection"), dict) else {} + if target_read.get("status") not in {"ok", "summary", "text"} or target_selection.get("status") == "not_found": + unresolved_calls.append({"from": source_id, **call, "reason": "Routine was not resolved through the bounded public selector.", "read_status": target_read.get("status")}) + continue + seen.add(key) + target_id = f"n{len(nodes) + 1}" + routine = target_selection.get("routine_name") or call.get("routine_name") + nodes.append({ + "id": target_id, "depth": current_depth + 1, "owner": {key: value for key, value in target_owner.items() if not key.startswith("_")}, "routine_name": routine, + "line_start": target_selection.get("line_start"), "line_end": target_selection.get("line_end"), + "read_selector": {"method": "code.read", "base_id": base_id_or_error, **({"ref": target_owner.get("ref")} if target_owner.get("ref") else {}), "routine_name": routine}, + }) + edges.append({"from": source_id, "to": target_id, **call, "status": "resolved"}) + queue.append((target_id, current_depth + 1, target_owner, target_read)) + if len(nodes) >= max_nodes: + break return { - "schema": "onec_diagnostics_call_chain.v1", + "schema": "onec_diagnostics_call_chain.v2", "status": "ok", "base_id": base_id_or_error, "entry": { "method": method_name, "object": { - "kind": object_selector.get("kind"), - "name": object_selector.get("name"), - "guid": object_selector.get("guid"), + "kind": entry_owner.get("kind"), + "name": entry_owner.get("name"), + "guid": entry_owner.get("guid"), }, }, - "chain": call_nodes, - "usage": { - "status": usage.get("status"), - "matches": len(usage.get("items") or []), - "items": usage.get("items") or [], - }, + "override_chain": override.get("chain") if isinstance(override.get("chain"), list) else [], + "nodes": nodes, + "edges": edges, + "unresolved_calls": unresolved_calls, "risks": [ - "Диагностика формирует приблизительный статический граф (без исполнения, без runtime данных).", + "Static graph only: dynamic dispatch, string-built calls, and runtime-created form elements are not inferred.", + "Only same-owner and one-level CommonModule.Routine calls are automatically resolved.", ], - "counts": {"chain_nodes": len(call_nodes), "usage_matches": len(usage.get("items") or [])}, + "counts": {"nodes": len(nodes), "edges": len(edges), "unresolved_calls": len(unresolved_calls), "depth": depth, "max_nodes": max_nodes, "truncated": bool(queue and len(nodes) >= max_nodes)}, } @@ -26044,9 +34150,10 @@ 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) + container_bsl_mode = any(isinstance(edit, dict) and edit.get("bsl_container") is True for edit in edits) path_mode = any(isinstance(edit, dict) and "path" in edit for edit in edits) structural_mode = any(isinstance(edit, dict) and ("swap_paths" in edit or "append_child" in edit or "replace_root" in edit) for edit in edits) - if sum(1 for value in (stream_mode, path_mode, structural_mode) if value) > 1: + if sum(1 for value in (stream_mode, container_bsl_mode, path_mode, structural_mode) if value) > 1: return { "schema": "onec_adapter_request_error.v1", "method": "changes.propose", @@ -26059,8 +34166,38 @@ def changes_propose(payload: dict[str, Any]) -> dict[str, Any]: decoded = decode_payload_lossless(data) applied_edits = [] proposal_text = None - if stream_mode: - from parser.cas_payload import replace_stream_block, stream_blocks_with_data + if container_bsl_mode: + if len(edits) != 1 or not isinstance(edits[0], dict): + raise ValueError("Exactly one root BSL-container edit is required") + edit = edits[0] + container_text = str(decoded.get("text") or "") + bsl_text, extraction = extract_bsl_text_from_container(container_text) + if extraction.get("status") != "ok": + raise ValueError("root BSL container start was not proven") + replace = edit.get("replace") if isinstance(edit.get("replace"), dict) else None + if replace is None: + raise ValueError("root BSL-container write currently supports exact replace only") + old, new = str(replace.get("old") or ""), str(replace.get("new") or "") + if not old: + raise ValueError("replace.old is required") + occurrences = bsl_text.count(old) + if occurrences != 1: + raise ValueError(f"replace.old must occur exactly once in root BSL text; found {occurrences}") + expected_text_sha1 = str(edit.get("expected_text_sha1") or "") + if expected_text_sha1 and expected_text_sha1.lower() != code_text_sha1(bsl_text): + raise ValueError("expected_text_sha1 does not match root BSL text") + patched_bsl = bsl_text.replace(old, new, 1) + offset = int(extraction.get("bsl_offset") or 0) + proposal_text = container_text[:offset] + patched_bsl + encoded = encode_payload_lossless(decoded, text=proposal_text) + applied_edits.append({"index": 0, "mode": "root_bsl_container", "bsl_offset": offset, "old_text_sha1": code_text_sha1(bsl_text), "new_text_sha1": code_text_sha1(patched_bsl), "occurrences": 1}) + elif stream_mode: + from parser.cas_payload import ( + classify_payload, + replace_declared_utf8_bsl_prefix_same_width, + replace_stream_block, + stream_blocks_with_data, + ) current_payload = decoded.get("payload") if not isinstance(current_payload, (bytes, bytearray)): @@ -26072,6 +34209,54 @@ def changes_propose(payload: dict[str, Any]) -> dict[str, Any]: replace = edit.get("replace") if isinstance(edit.get("replace"), dict) else None routine = edit.get("routine") if isinstance(edit.get("routine"), dict) else None stream_index = int(edit.get("stream_index")) + # Some report-object carriers store BSL in the declared prefix + # of a fixed-size member followed by opaque platform state. + # Only that evidenced *tail-bearing* layout requires the + # fixed-width codec. A declared prefix whose length equals + # its member length is an ordinary complete stream: the + # generic writer is proven to rebuild its header and may + # safely change source length. Treating both layouts as + # fixed-width blocked normal BSL edits unnecessarily. + classified_streams = classify_payload( + encode_payload_lossless(decoded, payload=modified_payload), include_text=True + ).get("stream_blocks") or [] + candidate_stream = classified_streams[stream_index] if 0 <= stream_index < len(classified_streams) else {} + declared_prefix = candidate_stream.get("declared_utf8_bsl_prefix") if isinstance(candidate_stream, dict) else None + if isinstance(declared_prefix, dict) and int(declared_prefix.get("opaque_tail_bytes") or 0) > 0: + if routine is not None or not isinstance(replace, dict): + return { + "schema": "onec_change_proposal.v1", + "status": "unsupported", + "error": "report_object_module_fixed_width_replace_only", + "applied": False, + "base_id": base_id, + "source": response_source, + "diagnostics": {"message": "This carrier currently permits only an exact old/new replacement that fits its fixed declared BSL prefix."}, + } + declared_text = str(candidate_stream.get("text") or "") + old = str(replace.get("old") or "") + new = str(replace.get("new") or "") + if not old: + raise ValueError("replace.old is required") + line_ending = "\r\n" if "\r\n" in declared_text else "\r" if "\r" in declared_text else "\n" + source_old = old if old in declared_text else old.replace("\r\n", "\n").replace("\r", "\n").replace("\n", line_ending) + source_new = new if source_old == old and "\n" not in new and "\r" not in new else new.replace("\r\n", "\n").replace("\r", "\n").replace("\n", line_ending) + occurrences = declared_text.count(source_old) + if occurrences != 1: + raise ValueError(f"replace.old must occur exactly once in declared BSL prefix; found {occurrences}") + patched_text = declared_text.replace(source_old, source_new, 1) + expected_text_sha1 = str(edit.get("expected_text_sha1") or "") if "expected_text_sha1" in edit else None + modified_payload, stream_edit = replace_declared_utf8_bsl_prefix_same_width( + modified_payload, + stream_index, + text=patched_text, + expected_text_sha1=expected_text_sha1, + ) + stream_edit["index"] = index + stream_edit["mode"] = "declared_utf8_bsl_prefix_same_width" + stream_edit["occurrences"] = occurrences + applied_edits.append(stream_edit) + continue blocks = stream_blocks_with_data(modified_payload) block = blocks[stream_index] if 0 <= stream_index < len(blocks) else None raw_stream_text = str((block or {}).get("text") or "") @@ -26507,6 +34692,7 @@ def decode_metadata_object(payload: dict[str, Any]) -> dict[str, Any]: if isinstance(table_or_error, dict): return table_or_error table = table_or_error + extension_route_file_name = "" include_storage, include_storage_error = strict_include_storage(payload, "metadata.object.decode") if include_storage_error: return include_storage_error @@ -26531,6 +34717,36 @@ def decode_metadata_object(payload: dict[str, Any]) -> dict[str, Any]: object_card: dict[str, Any] | None = None guid = str(payload.get("guid") or "").strip().lower() kind = canonical_kind(str(payload.get("kind") or "")) if payload.get("kind") else None + extension_filter = str(payload.get("extension") or "").strip() + if extension_filter and not guid and str(payload.get("name") or "").strip(): + resolved_extension_guid, extension_error = extension_filter_to_guid( + base_id, extension_filter, method="metadata.object.decode" + ) + if extension_error: + return extension_error + extension_match = fast_extension_object_exact_match( + base_id, + kind=kind, + name=str(payload.get("name") or ""), + extension_guid=resolved_extension_guid, + table="ConfigCAS", + timeout_seconds=timeout_seconds, + ) + if extension_match: + route = extension_match.get("route") if isinstance(extension_match.get("route"), dict) else {} + extension_route_file_name = str(route.get("file_name") or "") + table = str(route.get("table") or "ConfigCAS") + guid = str(extension_match.get("guid") or "").strip().lower() + kind = canonical_kind(str(extension_match.get("kind") or kind or "")) + object_card = { + "guid": guid, + "kind": kind, + "kind_ru": RU_KIND.get(kind or "", kind), + "name": extension_match.get("name"), + "synonym": extension_match.get("synonym"), + "origin": extension_match.get("origin"), + "route": route, + } if not guid: ordinal_value = first_non_empty_arg(payload, "ordinal", "index", "object_index") if ordinal_value is not None and ordinal_value != "": @@ -26601,7 +34817,7 @@ def decode_metadata_object(payload: dict[str, Any]) -> dict[str, Any]: object_card = saved_object_result.get("object") or object_card kind = canonical_kind(str((object_card or {}).get("kind") or kind or "")) if (object_card or kind) else None - storage_file_name = f"{extension_guid}__{guid}" if table == "ConfigCASSave" and extension_guid else guid + storage_file_name = extension_route_file_name or (f"{extension_guid}__{guid}" if table == "ConfigCASSave" and extension_guid else guid) data, config, error = read_storage_file_bytes(base_id, table, storage_file_name, timeout_seconds=timeout_seconds) if error: error["method"] = "metadata.object.decode" @@ -26816,15 +35032,54 @@ def metadata_object_parts(payload: dict[str, Any]) -> dict[str, Any]: part_limit, part_limit_error = parse_int_argument(payload, "part_limit", method="metadata.object.parts", default=200, minimum=1, maximum=5000) if part_limit_error: return part_limit_error - guid, kind, object_card, error = resolve_object_guid( - payload, - base_id, - timeout_seconds=timeout_seconds, - method="metadata.object.parts", - table=table, - ) - if error: - return error + extension_filter = str(payload.get("extension") or "").strip() + extension_match: dict[str, Any] | None = None + manifest_entries: list[dict[str, Any]] = [] + if extension_filter and str(payload.get("name") or payload.get("object_name") or "").strip(): + resolved_extension_guid, extension_error = extension_filter_to_guid( + base_id, extension_filter, method="metadata.object.parts" + ) + if extension_error: + return extension_error + extension_match = fast_extension_object_exact_match( + base_id, + kind=payload.get("kind") or payload.get("object_type"), + name=str(payload.get("name") or payload.get("object_name") or ""), + extension_guid=resolved_extension_guid, + table="ConfigCAS", + timeout_seconds=timeout_seconds, + ) + if extension_match: + route = extension_match.get("route") if isinstance(extension_match.get("route"), dict) else {} + manifest_entries = [ + entry for entry in (extension_match.get("manifest_entries") or []) + if isinstance(entry, dict) and str(entry.get("cas_key") or "").strip() + ] + table = str(route.get("table") or "ConfigCAS") + guid = str(extension_match.get("guid") or "").strip().lower() + kind = canonical_kind(str(extension_match.get("kind") or payload.get("kind") or "")) + object_card = { + "guid": guid, + "kind": kind, + "kind_ru": RU_KIND.get(kind, kind), + "name": extension_match.get("name"), + "synonym": extension_match.get("synonym"), + "ref": object_selector_ref(kind, str(extension_match.get("name") or "")), + "origin": extension_match.get("origin"), + "route": route, + } + if not manifest_entries: + extension_match = None + if extension_match is None: + guid, kind, object_card, error = resolve_object_guid( + payload, + base_id, + timeout_seconds=timeout_seconds, + method="metadata.object.parts", + table=table, + ) + if error: + return error if table == "ConfigCASSave" and extension_guid and (not object_card or not object_card.get("name")): saved_object_result = get_object( kind, @@ -26871,21 +35126,25 @@ def metadata_object_parts(payload: dict[str, Any]) -> dict[str, Any]: "base_id": base_id, "diagnostics": {"message": f"Payload classifier is unavailable: {exc}"}, } - files = storage_files_list({"base_id": base_id, "table": table, "prefix": storage_prefix_guid, "limit": part_limit, "timeout_seconds": timeout_seconds, "_internal": True}) - if files.get("status") != "ok": - return public_error_result(files, include_storage=include_storage, method="metadata.object.parts") - file_names = [ - str(row.get("FileName") or "") - for row in files.get("files") or [] - if str(row.get("FileName") or "") == storage_prefix_guid - or str(row.get("FileName") or "").startswith(f"{storage_prefix_guid}.") - ] + if extension_match is not None: + file_names = [str(entry.get("cas_key") or "") for entry in manifest_entries][: int(part_limit or 200)] + else: + files = storage_files_list({"base_id": base_id, "table": table, "prefix": storage_prefix_guid, "limit": part_limit, "timeout_seconds": timeout_seconds, "_internal": True}) + if files.get("status") != "ok": + return public_error_result(files, include_storage=include_storage, method="metadata.object.parts") + file_names = [ + str(row.get("FileName") or "") + for row in files.get("files") or [] + if str(row.get("FileName") or "") == storage_prefix_guid + or str(row.get("FileName") or "").startswith(f"{storage_prefix_guid}.") + ] if kind == "Configuration" and guid and guid != storage_prefix_guid and guid not in file_names: file_names.insert(0, str(guid)) payloads, config, read_error = read_storage_files_bytes(base_id, table, file_names, timeout_seconds=timeout_seconds) if read_error: return public_error_result(read_error, include_storage=include_storage, method="metadata.object.parts") parts = [] + manifest_suffix_by_key = {str(entry.get("cas_key") or ""): str(entry.get("suffix") or "") for entry in manifest_entries} for file_name in sorted(payloads or {}, key=lambda value: (value != guid, value != storage_prefix_guid, value)): data = (payloads or {})[file_name] classified = classify_payload( @@ -26893,7 +35152,7 @@ def metadata_object_parts(payload: dict[str, Any]) -> dict[str, Any]: include_text=bool(include_text), include_tree=bool(include_tree), ) - suffix = "" if file_name == guid else file_name[len(storage_prefix_guid) :] + suffix = manifest_suffix_by_key.get(file_name, "" if file_name == guid else file_name[len(storage_prefix_guid) :]) part = { "part_id": file_name, "suffix": suffix, @@ -26942,10 +35201,94 @@ def metadata_object_parts(payload: dict[str, Any]) -> dict[str, Any]: "counts": {"parts": len(parts), "roles": dict(sorted(role_counts.items()))}, "diagnostics": { "note": "Диагностические координаты частей скрыты. Для служебной диагностики используйте include_storage=true.", + **({"extension_manifest": {"status": "resolved", "entries": len(manifest_entries)}} if extension_match is not None else {}), }, } +def fast_extension_object_exact_match( + base_id: str, + *, + kind: str | None, + name: str, + extension_guid: str | None, + table: str, + timeout_seconds: int, +) -> dict[str, Any] | None: + wanted_kind, wanted_name = parse_object_query(kind, name) + guid_filter = wanted_name.lower() if is_guid_text(wanted_name) else "" + cache_config, _ = sql_config_for_base(base_id) + cache_rows = extension_route_cache_lookup( + cache_config, + query="" if guid_filter else wanted_name, + kind_filter=wanted_kind, + guid_filter=guid_filter, + extension_guid=extension_guid, + limit=20, + ) + exact_cached: list[dict[str, Any]] = [] + for row in cache_rows: + if guid_filter: + is_exact = guid_filter in { + str(row.get("guid") or "").lower(), + str(row.get("descriptor_cas_key") or "").lower(), + str(row.get("object_base_id") or "").lower(), + } + else: + is_exact = normalize_exact(row.get("name") or "") == normalize_exact(wanted_name) + if not is_exact: + continue + recent_freshness = extension_route_cache_recent_freshness(row, ttl_seconds=300) + if recent_freshness: + exact_cached.append(extension_route_cache_row_to_match(base_id, row, include_storage=True, freshness=recent_freshness)) + continue + validated_row, freshness = validate_extension_route_cache_row( + base_id, + cache_config, + row, + timeout_seconds=min(timeout_seconds, 30), + ) + if validated_row: + exact_cached.append(extension_route_cache_row_to_match(base_id, validated_row, include_storage=True, freshness=freshness)) + if len(exact_cached) == 1: + return exact_cached[0] + + preferred_extension_guid = extension_guid + if not preferred_extension_guid and wanted_name and not guid_filter: + matching_extensions = [ + item + for item in extension_map_by_guid(base_id).values() + if normalize_exact(item.get("name") or "") == normalize_exact(wanted_name) + ] + if len(matching_extensions) == 1: + preferred_extension_guid = str(matching_extensions[0].get("guid") or "").lower() + if not preferred_extension_guid: + return None + result = extension_objects_find( + { + "base_id": base_id, + "extension": preferred_extension_guid, + "kind": wanted_kind, + **({"guid": guid_filter} if guid_filter else {"query": wanted_name}), + "state": "save" if table == "ConfigCASSave" else "active", + "limit": 20, + "include_storage": True, + "timeout_seconds": timeout_seconds, + } + ) + exact_live = [ + item + for item in result.get("objects") or [] + if isinstance(item, dict) + and (not wanted_kind or canonical_kind(str(item.get("kind") or "")) == wanted_kind) + and ( + (guid_filter and str(item.get("guid") or "").lower() == guid_filter) + or (not guid_filter and normalize_exact(item.get("name") or "") == normalize_exact(wanted_name)) + ) + ] + return exact_live[0] if len(exact_live) == 1 else None + + def metadata_object_modules(payload: dict[str, Any]) -> dict[str, Any]: payload = normalize_object_selector_aliases(payload, "metadata.object.modules") if isinstance(payload, dict) and payload.get("status") == "invalid_argument": @@ -26957,6 +35300,12 @@ def metadata_object_modules(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("metadata.object.modules", "extension_guid", "extension_guid must be a GUID string.") + extension_filter = str(payload.get("extension") or "").strip() + if extension_filter and not extension_guid: + resolved_extension_guid, extension_error = extension_filter_to_guid(base_id, extension_filter, method="metadata.object.modules") + if extension_error: + return extension_error + extension_guid = str(resolved_extension_guid or "") timeout_value, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.modules", default=60, minimum=1) if timeout_error: return timeout_error @@ -26982,6 +35331,34 @@ def metadata_object_modules(payload: dict[str, Any]) -> dict[str, Any]: if isinstance(table_or_error, dict): return table_or_error table = table_or_error + if extension_guid and "table" not in payload: + table = "ConfigCAS" + if "table" not in payload and not extension_guid and not truthy(payload.get("_extension_auto_resolved")): + extension_match = fast_extension_object_exact_match( + base_id, + kind=payload.get("kind"), + name=str(payload.get("name") or payload.get("guid") or ""), + extension_guid=None, + table=table, + timeout_seconds=timeout_seconds, + ) + if extension_match: + origin = extension_match.get("origin") if isinstance(extension_match.get("origin"), dict) else {} + extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} + route = extension_match.get("route") if isinstance(extension_match.get("route"), dict) else {} + return metadata_object_modules( + { + **payload, + "kind": extension_match.get("kind") or payload.get("kind"), + "name": extension_match.get("name") or payload.get("name"), + "guid": extension_match.get("guid") or payload.get("guid"), + "extension": extension.get("name"), + "extension_guid": extension.get("guid"), + "table": route.get("table") or "ConfigCAS", + **({"file_name": route.get("file_name")} if route.get("file_name") else {}), + "_extension_auto_resolved": True, + } + ) object_probe = get_object( payload.get("kind"), str(payload.get("name") or payload.get("guid") or ""), @@ -26994,8 +35371,35 @@ def metadata_object_modules(payload: dict[str, Any]) -> dict[str, Any]: include_semantic=False, timeout_seconds=timeout_seconds, extension_guid=extension_guid or None, + file_name=str(payload.get("file_name") or "").strip() or None, ) if object_probe.get("status") != "ok": + if not truthy(payload.get("_extension_auto_resolved")): + extension_match = fast_extension_object_exact_match( + base_id, + kind=payload.get("kind"), + name=str(payload.get("name") or payload.get("guid") or ""), + extension_guid=extension_guid or None, + table=table, + timeout_seconds=timeout_seconds, + ) + if extension_match: + origin = extension_match.get("origin") if isinstance(extension_match.get("origin"), dict) else {} + extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {} + route = extension_match.get("route") if isinstance(extension_match.get("route"), dict) else {} + return metadata_object_modules( + { + **payload, + "kind": extension_match.get("kind") or payload.get("kind"), + "name": extension_match.get("name") or payload.get("name"), + "guid": extension_match.get("guid") or payload.get("guid"), + "extension": extension.get("name") or extension_filter or None, + "extension_guid": extension.get("guid") or extension_guid or None, + "table": route.get("table") or ("ConfigCASSave" if table == "ConfigCASSave" else "ConfigCAS"), + **({"file_name": route.get("file_name")} if route.get("file_name") else {}), + "_extension_auto_resolved": True, + } + ) result = dict(object_probe) result["method"] = "metadata.object.modules" return result @@ -27005,12 +35409,31 @@ def metadata_object_modules(payload: dict[str, Any]) -> dict[str, Any]: cache_role = metadata_modules_cache_role() if config and object_guid and not truthy(payload.get("refresh_cache")): cached_result = metadata_guid_index_lookup_payload(config, object_guid, cache_role) + if cached_result and extension_guid and not any( + isinstance(module, dict) + and ( + str(module.get("kind") or "") == "form_module" + or isinstance(module.get("form"), dict) + ) + for module in cached_result.get("modules") or [] + ): + cached_result = None if cached_result: cached_object = cached_result.get("object") if isinstance(cached_result.get("object"), dict) else {} merged_object = { **cached_object, **{key: value for key, value in object_card.items() if value is not None and value != ""}, } + if extension_guid: + merged_object["origin"] = { + "source": "extension", + "presentation": "Расширение", + "extension": { + "guid": extension_guid, + **({"name": extension_filter} if extension_filter and not is_guid_text(extension_filter) else {}), + }, + "status": "ok", + } modules = [module for module in cached_result.get("modules") or [] if isinstance(module, dict)] owner_public = { "kind": merged_object.get("kind"), @@ -27038,6 +35461,24 @@ def metadata_object_modules(payload: dict[str, Any]) -> dict[str, Any]: if not module_id: continue metadata_module_owner_cache_upsert(config, module_id, owner_public, module=module) + form_payload = module.get("form") if isinstance(module.get("form"), dict) else {} + if form_payload: + module_table, module_file_name, _module_stream_index = parse_module_id(module_id) + merged_origin = merged_object.get("origin") if isinstance(merged_object.get("origin"), dict) else {} + merged_extension = merged_origin.get("extension") if isinstance(merged_origin.get("extension"), dict) else {} + if module_table in STORAGE_TABLES and module_file_name: + metadata_form_owner_cache_upsert( + config, + base_id=base_id, + owner_kind=owner_public.get("kind"), + owner_name=owner_public.get("name"), + owner_guid=owner_public.get("guid"), + form_name=form_payload.get("name"), + form_guid=form_payload.get("guid"), + table=module_table, + file_name=module_file_name, + extension=merged_extension or None, + ) matched_modules = [] for module, match_by in filter_public_rows_by_name(public_modules, requested_module): public_module = dict(module) @@ -27076,7 +35517,11 @@ def metadata_object_modules(payload: dict[str, Any]) -> dict[str, Any]: "guid": object_guid, "kind": object_card.get("kind") or payload.get("kind"), "table": table, - "include_text": False, + # Module ownership may be evidenced by a declared UTF-8 BSL prefix + # even when the module has no procedure/function (for example, an + # intentionally comment-only object module). This read is internal; + # public text remains controlled by the caller's code.read request. + "include_text": True, "include_tree": False, "include_storage": True, "timeout_seconds": timeout_seconds, @@ -27094,12 +35539,20 @@ def metadata_object_modules(payload: dict[str, Any]) -> dict[str, Any]: classification = part.get("classification") or {} owner_kind = canonical_kind(str(object_card.get("kind") or payload.get("kind") or "")) configuration_suffix = str(part.get("suffix") or "").lstrip(".") if owner_kind == "Configuration" else "" - if classification.get("role") != "bsl_module_payload" and configuration_suffix not in {"0", "5", "6", "7"}: + has_declared_bsl_prefix = any( + isinstance(stream, dict) and stream.get("declared_utf8_bsl_prefix") + for stream in (classification.get("stream_blocks") or []) + ) + if ( + classification.get("role") != "bsl_module_payload" + and not has_declared_bsl_prefix + and configuration_suffix not in {"0", "5", "6", "7"} + ): continue candidate_streams = [ (index, stream) for index, stream in enumerate(classification.get("stream_blocks") or []) - if stream.get("has_bsl_marker") + if stream.get("has_bsl_marker") or stream.get("declared_utf8_bsl_prefix") ] # A Config service part contains one logical BSL module. Some platform # versions also repeat a short tail/head fragment as another marked @@ -27138,17 +35591,119 @@ def metadata_object_modules(payload: dict[str, Any]) -> dict[str, Any]: "encoding": stream.get("encoding"), "text_preview": stream.get("text_preview"), "payload_role": classification.get("role"), + **( + {"code_evidence": "declared_utf8_bsl_prefix"} + if stream.get("declared_utf8_bsl_prefix") + else {} + ), } ) parts_object = parts_result.get("object") if isinstance(parts_result.get("object"), dict) else {} - owner_public = { - "kind": parts_object.get("kind") or object_card.get("kind"), - "kind_ru": parts_object.get("kind_ru") or object_card.get("kind_ru"), - "public_kind": parts_object.get("public_kind") or object_card.get("public_kind"), - "guid": object_guid, - "name": parts_object.get("name") or object_card.get("name"), - "synonym": parts_object.get("synonym") or object_card.get("synonym"), + resolved_object_card = { + **object_card, + **{key: value for key, value in parts_object.items() if value is not None and value != ""}, } + if extension_guid: + resolved_object_card["origin"] = { + "source": "extension", + "presentation": "Расширение", + "extension": { + "guid": extension_guid, + **({"name": extension_filter} if extension_filter and not is_guid_text(extension_filter) else {}), + }, + "status": "ok", + } + owner_public = { + "kind": resolved_object_card.get("kind"), + "kind_ru": resolved_object_card.get("kind_ru"), + "public_kind": resolved_object_card.get("public_kind"), + "guid": object_guid, + "name": resolved_object_card.get("name"), + "synonym": resolved_object_card.get("synonym"), + } + object_storage = resolved_object_card.get("storage") if isinstance(resolved_object_card.get("storage"), dict) else {} + forms_result = metadata_object_forms( + { + **payload, + "base_id": base_id, + "kind": owner_public.get("kind"), + "name": owner_public.get("name"), + "guid": owner_public.get("guid"), + "table": table, + **({"file_name": object_storage.get("file_name")} if object_storage.get("file_name") else {}), + "include_storage": True, + "timeout_seconds": timeout_seconds, + "_extension_auto_resolved": True, + } + ) + known_module_refs = {str(module.get("module_id") or "") for module in modules} + for form in forms_result.get("forms") or []: + if not isinstance(form, dict): + continue + form_source = form.get("source") if isinstance(form.get("source"), dict) else {} + form_table = str(form_source.get("table") or table) + form_file_name = str(form_source.get("file_name") or "") + if form_table not in STORAGE_TABLES or not form_file_name: + continue + form_module_ref = f"{form_table}:{form_file_name}#form_module" + if form_module_ref in known_module_refs: + continue + decoded_form = metadata_form_decode( + { + "base_id": base_id, + "table": form_table, + "file_name": form_file_name, + "include_storage": True, + "include_parameters": False, + "include_module": True, + "include_module_text": True, + "timeout_seconds": timeout_seconds, + } + ) + profile = decoded_form.get("profile") if isinstance(decoded_form.get("profile"), dict) else {} + decoded_module = profile.get("module") if isinstance(profile.get("module"), dict) else {} + decoded_text = str(decoded_module.get("text") or "") + # A form payload is not automatically a writable module. Register it + # as such only when its code was actually decoded from this payload. + # Otherwise a caller could overwrite a form container while the real + # BSL module lives in a separate, still-unresolved CAS record. + if not decoded_text.strip() or not is_bsl_like_text(decoded_text): + continue + form_payload = { + "guid": form.get("guid"), + "name": form.get("name"), + "synonym": next(iter((form.get("synonyms") or {}).values()), None) if isinstance(form.get("synonyms"), dict) else None, + } + modules.append( + { + "module_id": form_module_ref, + "table": form_table, + "file_name": form_file_name, + "kind": "form_module", + "name": "Модуль формы", + "form": form_payload, + "payload_role": "form_payload", + "code_evidence": "decoded_form_module", + } + ) + known_module_refs.add(form_module_ref) + metadata_form_owner_cache_upsert( + config, + base_id=base_id, + owner_kind=owner_public.get("kind"), + owner_name=owner_public.get("name"), + owner_guid=owner_public.get("guid"), + form_name=form_payload.get("name"), + form_guid=form_payload.get("guid"), + table=form_table, + file_name=form_file_name, + extension={ + "guid": extension_guid, + **({"name": extension_filter} if extension_filter and not is_guid_text(extension_filter) else {}), + } + if extension_guid + else None, + ) public_modules = [ public_module_with_qualified_name( module, @@ -27174,7 +35729,7 @@ def metadata_object_modules(payload: dict[str, Any]) -> dict[str, Any]: public_module["match_by"] = match_by matched_modules.append(public_module) if wanted_module and not matched_modules: - result = child_not_found("metadata.object.modules", "Модуль", requested_module, parts_result.get("object") or object_card, base_id=base_id) + result = child_not_found("metadata.object.modules", "Модуль", requested_module, resolved_object_card, base_id=base_id) result.update( { "schema": "onec_object_modules.v1", @@ -27190,7 +35745,7 @@ def metadata_object_modules(payload: dict[str, Any]) -> dict[str, Any]: "status": "ok", "base_id": base_id, "source": parts_result.get("source") if include_storage else {"kind": "live_metadata"}, - "object": parts_result.get("object") or object_card, + "object": resolved_object_card, "query": {"module": requested_module, "include_storage": include_storage}, "modules": matched_modules, "counts": {"modules": len(matched_modules), "available_modules": len(public_modules), "parts": (parts_result.get("counts") or {}).get("parts")}, @@ -27200,7 +35755,7 @@ def metadata_object_modules(payload: dict[str, Any]) -> dict[str, Any]: } if config and object_guid: cache_payload = { - "object": parts_result.get("object") or object_card, + "object": resolved_object_card, "modules": modules, "counts": {"modules": len(modules), "parts": (parts_result.get("counts") or {}).get("parts")}, "source": parts_result.get("source"), @@ -27242,6 +35797,14 @@ def metadata_object_related(payload: dict[str, Any]) -> dict[str, Any]: "extension_guid", "extension_guid must be a GUID string.", ) + extension_filter = str(payload.get("extension") or "").strip() + if extension_filter and not extension_guid: + resolved_extension_guid, extension_error = extension_filter_to_guid( + base_id, extension_filter, method="metadata.object.related" + ) + if extension_error: + return extension_error + extension_guid = str(resolved_extension_guid or "").strip().lower() timeout_value, timeout_error = parse_int_argument(payload, "timeout_seconds", method="metadata.object.related", default=60, minimum=1) if timeout_error: return timeout_error @@ -27377,6 +35940,30 @@ def metadata_object_related(payload: dict[str, Any]) -> dict[str, Any]: if object_table == "ConfigCASSave" and extension_guid else related_guid ) + manifest_match: dict[str, Any] | None = None + if extension_guid and object_table in {"ConfigCAS", "ConfigCASSave"}: + manifest_matches, _manifest_diagnostics, _manifest_stats = extension_manifest_object_matches( + base_id=base_id, + query="", + kind_filter=None, + guid_filter=str(related_guid).lower(), + extension_guid=extension_guid, + limit=1, + timeout_seconds=timeout_seconds, + include_storage=True, + ) + if manifest_matches: + manifest_match = manifest_matches[0] + manifest_route = manifest_match.get("route") if isinstance(manifest_match.get("route"), dict) else {} + manifest_file_name = str(manifest_route.get("file_name") or "").strip() + if manifest_file_name: + related_file_name = manifest_file_name + manifest_name = str(manifest_match.get("name") or "").strip() + if normalize("СхемаКомпоновкиДанных") in normalize(manifest_name): + item["category"] = "DataCompositionSchema" + item["classification_evidence"] = "extension_manifest_name" + if manifest_name: + item["name"] = manifest_name if include_storage: item["source"]["file_name"] = related_file_name related_data, _, related_error = read_storage_file_bytes(base_id, object_table, related_file_name, timeout_seconds=timeout_seconds) @@ -27406,6 +35993,24 @@ def metadata_object_related(payload: dict[str, Any]) -> dict[str, Any]: ) item["status"] = "ok" item["identity"] = config_identity_from_bytes(related_data) or (record_identity.to_dict() if record_identity else None) + # СКД в базовой конфигурации, как и в расширениях, хранится + # обычным Template. У расширений имя уточняется манифестом, + # у базовых объектов единственный надёжный публичный признак — + # подтверждённое имя из самого шаблона. + identity_name = str((item.get("identity") or {}).get("name") or "") + if ( + item.get("category") == "Template" + and normalize("СхемаКомпоновкиДанных") in normalize(identity_name) + ): + item["category"] = "DataCompositionSchema" + item["classification_evidence"] = "template_identity_name" + # The decoded Template identity is the public 1C name of a + # base SCD. Promote it after classification so callers can + # select the exact schema by name without using a GUID. + if item.get("category") == "DataCompositionSchema" and identity_name: + item["name"] = identity_name + if manifest_match: + item["route_resolution"] = "extension_manifest_guid" if include_storage: item["classification"] = {key: value for key, value in classified.items() if key not in {"text", "tree"}} related.append(item) @@ -27435,6 +36040,1854 @@ def metadata_object_related(payload: dict[str, Any]) -> dict[str, Any]: } +def scd_inspect(payload: dict[str, Any]) -> dict[str, Any]: + """Resolve and decode one report SCD from its SQL ConfigCAS payload.""" + method = "scd.inspect" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + report = str(payload.get("report") or payload.get("name") or payload.get("object_name") or "").strip() + if not report: + return invalid_argument(method, "report", "report is required as a public 1C report name.") + schema_name = str(payload.get("schema") or "").strip() + requested_sections = payload.get("sections") or ["parameters", "datasets", "fields", "calculated_fields", "resources", "settings", "variants", "total_fields"] + if not isinstance(requested_sections, list) or not all(isinstance(item, str) for item in requested_sections): + return invalid_argument(method, "sections", "sections must be an array of strings.") + resolve_metadata, resolve_metadata_error = strict_bool_argument(payload, "resolve_metadata", method=method, default=True) + if resolve_metadata_error: + return resolve_metadata_error + related_payload = { + "base_id": base_id, + "kind": "Report", + "name": report, + "include_storage": True, + "timeout_seconds": int(payload.get("timeout_seconds") or 60), + } + if payload.get("extension") not in {None, ""}: + related_payload["extension"] = payload.get("extension") + if payload.get("extension_guid") not in {None, ""}: + related_payload["extension_guid"] = payload.get("extension_guid") + related = metadata_object_related(related_payload) + if related.get("status") != "ok": + related["method"] = method + return related + schemas = [item for item in related.get("related") or [] if isinstance(item, dict) and item.get("category") == "DataCompositionSchema" and item.get("status") == "ok"] + discovered_schemas = list(schemas) + if schema_name: + schemas = [item for item in schemas if normalize(str(item.get("name") or "")) == normalize(schema_name)] + if not schemas: + unnamed_schemas = [item for item in discovered_schemas if not str(item.get("name") or "").strip()] + if schema_name and unnamed_schemas: + return { + "schema": "onec_scd_inspect.v1", "method": method, "status": "partial", "error": "scd_schema_name_not_proven", + "base_id": base_id, "extension": payload.get("extension"), "report": report, "requested_schema": schema_name, + "schemas": [{"name": item.get("name"), "guid": item.get("guid")} for item in discovered_schemas], + "diagnostics": {"code": "SCD_SCHEMA_NAME_NOT_PROVEN", "message": "An SCD payload is proven, but its name is absent from decoded SQL metadata; the requested template name cannot be matched safely.", "next_call": {"method": method, "params": {"base_id": base_id, "report": report}}}, + } + return { + "schema": "onec_scd_inspect.v1", "method": method, "status": "not_found", "error": "scd_schema_not_found", + "base_id": base_id, "extension": payload.get("extension"), "report": report, **({"requested_schema": schema_name} if schema_name else {}), + "diagnostics": {"code": "SCD_SCHEMA_NOT_FOUND", "message": "No DataCompositionSchema was proven by the report related-object graph."}, + } + if len(schemas) > 1: + return { + "schema": "onec_scd_inspect.v1", "method": method, "status": "ambiguous", "base_id": base_id, + "extension": payload.get("extension"), "report": report, + "schemas": [{"name": item.get("name"), "guid": item.get("guid")} for item in schemas], + "diagnostics": {"code": "SCD_SCHEMA_AMBIGUOUS", "message": "Several schemas were found; pass schema exactly."}, + } + schema = schemas[0] + timeout_seconds = int(payload.get("timeout_seconds") or 60) + extension_guid = str(payload.get("extension_guid") or "").strip().lower() + if not extension_guid and payload.get("extension") not in {None, ""}: + extension_guid, extension_error = extension_filter_to_guid(base_id, str(payload.get("extension")), method=method) + if extension_error: + extension_guid = "" + schema_manifest: dict[str, Any] = {} + if extension_guid: + matches, _, _ = extension_manifest_object_matches( + base_id=base_id, + query="", + kind_filter=None, + guid_filter=str(schema.get("guid") or "").lower(), + extension_guid=extension_guid, + limit=1, + timeout_seconds=timeout_seconds, + include_storage=True, + ) + match = matches[0] if matches else None + entries = match.get("manifest_entries") if isinstance(match, dict) and isinstance(match.get("manifest_entries"), list) else [] + payload_entry = next((entry for entry in entries if isinstance(entry, dict) and str(entry.get("suffix") or "") == ".0" and str(entry.get("cas_key") or "").strip()), None) + if payload_entry: + schema_manifest = {"table": "ConfigCAS", "file_name": str(payload_entry.get("cas_key")), "suffix": ".0"} + decoded: dict[str, Any] | None = None + artifact_cache: dict[str, Any] | None = None + if not extension_guid: + # В базовой конфигурации XML СКД не имеет манифеста расширения: он + # лежит в одном из потоков Template с тем же GUID. Проверяем только + # доказанную группу файлов этого шаблона, а не сканируем Config целиком. + base_route = schema.get("source") if isinstance(schema.get("source"), dict) else {} + template_file_name = str(base_route.get("file_name") or schema.get("guid") or "").strip() + if template_file_name: + cache_config, _ = sql_config_for_base(base_id) + cached_route = decoded_artifact_cache_lookup_source_prefix( + cache_config, artifact_kind=SCD_TEMPLATE_ARTIFACT_KIND, source_table="Config", source_file_prefix=template_file_name, + ) + if cached_route: + cached_file = str(cached_route.get("source_file") or "") + cached_bytes, _, cached_error = read_storage_file_bytes(base_id, "Config", cached_file, timeout_seconds=min(timeout_seconds, 60)) + cached_artifact = cached_route.get("artifact") if isinstance(cached_route.get("artifact"), dict) else {} + cached_sections = cached_artifact.get("sections_requested") if isinstance(cached_artifact.get("sections_requested"), list) else [] + cached_decoded = cached_artifact.get("decoded") if isinstance(cached_artifact.get("decoded"), dict) else None + if cached_bytes and not cached_error and hashlib.sha1(cached_bytes).hexdigest() == str(cached_route.get("content_sha1") or "") and cached_decoded and set(requested_sections) <= set(cached_sections): + schema_manifest = {"table": "Config", "file_name": cached_file} + decoded = cached_decoded + artifact_cache = {"status": "live_sha1_verified_hit", "content_sha1": cached_route.get("content_sha1")} + files = storage_files_list({ + "base_id": base_id, "table": "Config", "prefix": template_file_name, + "limit": 100, "timeout_seconds": min(timeout_seconds, 60), "_internal": True, + }) + if decoded is None and files.get("status") == "ok": + try: + from parser.scd_payload import inspect_scd_payload + for row in files.get("files") or []: + candidate_name = str(row.get("FileName") or "").strip() + if not candidate_name: + continue + candidate_bytes, _, candidate_error = read_storage_file_bytes( + base_id, "Config", candidate_name, timeout_seconds=min(timeout_seconds, 60), + ) + if candidate_error or not candidate_bytes: + continue + candidate = inspect_scd_payload(candidate_bytes, sections=requested_sections) + if candidate.get("status") == "ok": + schema_manifest = {"table": "Config", "file_name": candidate_name} + decoded = candidate + candidate_sha1 = hashlib.sha1(candidate_bytes).hexdigest() + decoded_artifact_cache_upsert( + cache_config, artifact_kind=SCD_TEMPLATE_ARTIFACT_KIND, content_sha1=candidate_sha1, + source_table="Config", source_file=candidate_name, payload_bytes=len(candidate_bytes), + artifact={"sections_requested": requested_sections, "decoded": candidate}, + ) + artifact_cache = {"status": "live_sha1_verified_miss_stored", "content_sha1": candidate_sha1} + break + except Exception as exc: + decoded = {"status": "partial", "sections": {section: [] for section in requested_sections}, "error": {"code": "SCD_DECODER_ERROR", "message": str(exc)}} + if schema_manifest: + if decoded is None: + schema_bytes, _, read_error = read_storage_file_bytes( + base_id, + str(schema_manifest.get("table") or "ConfigCAS"), + str(schema_manifest["file_name"]), + timeout_seconds=min(timeout_seconds, 60), + ) + if read_error: + decoded = {"status": "partial", "sections": {section: [] for section in requested_sections}, "error": read_error} + elif schema_bytes: + try: + from parser.scd_payload import inspect_scd_payload + decoded = inspect_scd_payload(schema_bytes, sections=requested_sections) + except Exception as exc: + decoded = {"status": "partial", "sections": {section: [] for section in requested_sections}, "error": {"code": "SCD_DECODER_ERROR", "message": str(exc)}} + if decoded and decoded.get("status") == "ok": + analysis = dict(decoded.get("analysis") or {}) + source_scan = analysis.get("data_source_references") if isinstance(analysis.get("data_source_references"), dict) else {} + source_kind_map = { + "регистрнакопления": "AccumulationRegister", "регистрсведений": "InformationRegister", + "регистрбухгалтерии": "AccountingRegister", "регистррасчета": "CalculationRegister", + "справочник": "Catalog", "документ": "Document", "планвидовхарактеристик": "ChartOfCharacteristicTypes", + } + resolved_sources: list[dict[str, Any]] = [] + for dataset in (source_scan.get("datasets") or []) if resolve_metadata else []: + if not isinstance(dataset, dict): + continue + for source_name in dataset.get("sources") or []: + parts = str(source_name or "").split(".", 1) + kind = source_kind_map.get(normalize(parts[0])) if len(parts) == 2 else None + if not kind: + resolved_sources.append({"dataset": dataset.get("dataset"), "source": source_name, "status": "unsupported_source_kind"}) + continue + resolved = get_object(kind, parts[1], base_id=base_id, view="effective", limit=2, include_semantic=False, timeout_seconds=min(timeout_seconds, 30), table="Config") + card = resolved.get("object") if isinstance(resolved, dict) and isinstance(resolved.get("object"), dict) else {} + resolved_sources.append({ + "dataset": dataset.get("dataset"), "source": source_name, "kind": kind, + "name": parts[1], "status": "resolved" if resolved.get("status") == "ok" and card else "not_found", + **({"object": public_metadata_row(card)} if card else {}), + }) + if resolved_sources: + analysis["data_source_resolution"] = {"kind": "live_sql_metadata_resolution", "sources": resolved_sources} + direct_references = analysis.get("query_direct_field_references") if isinstance(analysis.get("query_direct_field_references"), dict) else {} + if direct_references and resolved_sources: + source_by_alias: dict[tuple[str, str], dict[str, Any]] = {} + for dataset in source_scan.get("datasets") or []: + if not isinstance(dataset, dict): + continue + for binding in dataset.get("bindings") or []: + if isinstance(binding, dict) and binding.get("alias"): + source_by_alias[(str(dataset.get("dataset") or ""), str(binding.get("alias")))] = binding + source_objects = {str(item.get("source") or ""): item for item in resolved_sources if item.get("status") == "resolved"} + source_fields: dict[str, set[str]] = {} + field_resolution: list[dict[str, Any]] = [] + for dataset in direct_references.get("datasets") or []: + if not isinstance(dataset, dict): + continue + dataset_name = str(dataset.get("dataset") or "") + for reference in dataset.get("references") or []: + alias, field = str(reference.get("alias") or ""), str(reference.get("field") or "") + binding = source_by_alias.get((dataset_name, alias)) or {} + source_name = str(binding.get("source") or "") + source_object = source_objects.get(source_name) or {} + card = source_object.get("object") if isinstance(source_object.get("object"), dict) else {} + if not card: + field_resolution.append({"dataset": dataset_name, "alias": alias, "field": field, "status": "source_not_resolved"}) + continue + object_ref = str(card.get("ref") or source_name) + if object_ref not in source_fields: + fields_result = metadata_object_attributes({"base_id": base_id, "kind": card.get("kind"), "guid": card.get("guid"), "only": "all", "timeout_seconds": min(timeout_seconds, 30)}) + names: set[str] = set() + if fields_result.get("status") == "ok": + for section_name in ("attributes", "dimensions", "resources"): + names.update(normalize(str(item.get("name") or "")) for item in fields_result.get(section_name) or [] if item.get("name")) + source_fields[object_ref] = names + field_resolution.append({ + "dataset": dataset_name, "alias": alias, "field": field, "source": source_name, + "status": "resolved" if normalize(field) in source_fields[object_ref] else "not_declared_in_decoded_fields", + }) + if field_resolution: + analysis["direct_field_resolution"] = {"kind": "live_sql_field_metadata_resolution", "references": field_resolution} + return { + "schema": "onec_scd_inspect.v1", "method": method, "status": "ok", "base_id": base_id, + "extension": payload.get("extension"), "report": report, + "configuration_view": configuration_view_details(str(payload.get("configuration_view") or "effective_working")), + "activation_state": "working_not_runtime_applied", + "scd": {"name": schema.get("name"), "guid": schema.get("guid"), "source": "extension_manifest_guid" if extension_guid else "template_payload_group", "payload_route": schema_manifest, "container": decoded.get("container")}, + "sections": decoded.get("sections") or {}, + "analysis": analysis, + "metadata_resolution": "live_sql" if resolve_metadata else "skipped_for_field_scoped_static_analysis", + **({"artifact_cache": artifact_cache} if artifact_cache else {}), + "diagnostics": decoded.get("diagnostics") or {}, + "unresolved": [], + } + return { + "schema": "onec_scd_inspect.v1", "method": method, "status": "partial", "base_id": base_id, + "extension": payload.get("extension"), "report": report, + "configuration_view": configuration_view_details(str(payload.get("configuration_view") or "effective_working")), + "activation_state": "working_not_runtime_applied", + "scd": {"name": schema.get("name"), "guid": schema.get("guid"), "source": "extension_manifest_guid" if extension_guid else "template_payload_group", "read_selector": schema.get("source"), **({"payload_route": schema_manifest} if schema_manifest else {})}, + "sections": {section: [] for section in requested_sections}, + "unresolved": [{"code": "SCD_PAYLOAD_DECODE_UNAVAILABLE", "source": "schema_payload" if schema_manifest else "schema", "message": "Schema route is proven, but the SQL payload could not be decoded.", **({"details": decoded.get("error") or decoded.get("container")} if decoded else {})}], + } + + +def scd_prepare(payload: dict[str, Any]) -> dict[str, Any]: + """Prepare every active SQL file needed by one report SCD without exposing file keys.""" + method = "scd.prepare" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + report = str(payload.get("report") or payload.get("name") or "").strip() + extension = str(payload.get("extension") or "").strip() + if not report: + return invalid_argument(method, "report", "report is required as a public 1C report name.") + timeout_seconds = int(payload.get("timeout_seconds") or 60) + inspect = scd_inspect({ + "base_id": base_id, **({"extension": extension} if extension else {}), "report": report, + "schema": payload.get("schema"), "timeout_seconds": timeout_seconds, + }) + if inspect.get("status") != "ok": + return {"schema": "onec_scd_prepare.v1", "method": method, "status": inspect.get("status"), "base_id": base_id, "inspect": inspect} + layer = "extension_saved_state" if extension else "base_saved_state" + if extension: + prepare_payload = { + "base_id": base_id, "extension": extension, "ref": f"Report.{report}", "layer": layer, + "mode": str(payload.get("mode") or "plan"), "merge_missing": True, "timeout_seconds": timeout_seconds, + } + else: + report_files, _object_card, files_error = saved_state_prepare_file_names( + {"ref": f"Report.{report}", "kind": "Report", "name": report}, base_id, "Config", timeout_seconds, + ) + if files_error: + return {"schema": "onec_scd_prepare.v1", "method": method, "status": files_error.get("status") or "error", "base_id": base_id, "error": "scd_report_files_not_resolved", "diagnostics": files_error.get("diagnostics")} + route = (inspect.get("scd") or {}).get("payload_route") if isinstance((inspect.get("scd") or {}).get("payload_route"), dict) else {} + payload_file_name = str(route.get("file_name") or "").strip() + template_prefix = payload_file_name.split(".", 1)[0] + template_files: list[str] = [] + if template_prefix: + listed = storage_files_list({ + "base_id": base_id, "table": "Config", "prefix": template_prefix, "limit": 100, + "timeout_seconds": min(timeout_seconds, 60), "_internal": True, + }) + if listed.get("status") == "ok": + template_files = [ + str(row.get("FileName") or "") for row in listed.get("files") or [] + if str(row.get("FileName") or "") == template_prefix or str(row.get("FileName") or "").startswith(f"{template_prefix}.") + ] + all_files = sorted(set(report_files + template_files)) + if payload_file_name and payload_file_name not in all_files: + return {"schema": "onec_scd_prepare.v1", "method": method, "status": "partial", "base_id": base_id, "report": report, "diagnostics": {"code": "SCD_TEMPLATE_GROUP_NOT_RESOLVED", "message": "The active SCD template payload group could not be resolved for saved-state preparation."}} + prepare_payload = { + "base_id": base_id, "ref": f"Report.{report}", "kind": "Report", "name": report, + "file_names": all_files, "layer": layer, + "mode": str(payload.get("mode") or "plan"), "timeout_seconds": timeout_seconds, + } + for flag in ("allow_sql_saved_state_prepare", "include_storage", "merge_missing"): + if flag in payload: + prepare_payload[flag] = payload[flag] + # Keep the preparation receipt internally even for the public response: + # it is an opaque rollback capability, not a storage coordinate. + public_include_storage = bool(payload.get("include_storage") is True) + prepare_payload["include_storage"] = True + prepared = metadata_saved_state_prepare(prepare_payload) + receipt = ((prepared.get("apply_result") or {}).get("prepare_receipt") or {}) if isinstance(prepared.get("apply_result"), dict) else {} + return { + "schema": "onec_scd_prepare.v1", "method": method, "status": prepared.get("status"), "base_id": base_id, + "extension": extension or None, "report": report, "schema_name": (inspect.get("scd") or {}).get("name"), + "prepared": public_saved_state_prepare_embedded_result(prepared, {**prepare_payload, "include_storage": public_include_storage}), + **({"prepare_receipt_id": receipt.get("receipt_id")} if receipt.get("receipt_id") else {}), + "write_mode": {"target": "saved_state", "sql_write_performed": bool(prepared.get("applied")), "requires_allow_flag": True}, + } + + +def scd_prepare_rollback(payload: dict[str, Any]) -> dict[str, Any]: + """Rollback only rows inserted by a previous scd.prepare apply receipt.""" + method = "scd.prepare.rollback" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + receipt_id = str(payload.get("prepare_receipt_id") or "").strip().lower() + if not re.fullmatch(r"[0-9a-f]{32}", receipt_id): + return invalid_argument(method, "prepare_receipt_id", "prepare_receipt_id must be the 32-character receipt returned by scd.prepare.") + allowed, allowed_error = strict_bool_argument(payload, "allow_sql_saved_state_rollback", method=method, default=False) + if allowed_error: + return allowed_error + if not allowed: + return invalid_argument(method, "allow_sql_saved_state_rollback", "Rollback is opt-in; pass allow_sql_saved_state_rollback=true after verifying the test result.") + result = rollback_saved_state_prepare_receipt(base_id_or_error, receipt_id, timeout_seconds=int(payload.get("timeout_seconds") or 60)) + return {"schema": "onec_scd_prepare_rollback.v1", "method": method, "base_id": base_id_or_error, "prepare_receipt_id": receipt_id, **result} + + +def scd_patch_prepare_and_retry( + payload: dict[str, Any], + *, + base_id: str, + extension: str, + report: str, + timeout_seconds: int, + inspect: dict[str, Any], +) -> dict[str, Any] | None: + """Prepare a missing saved SCD only inside an explicitly gated apply route.""" + execution_mode = str(payload.get("execution_mode") or "plan").strip().casefold() + if execution_mode not in {"apply", "apply_and_verify", "apply_and_rollback"}: + return None + if payload.get("allow_saved_state_write") is not True: + return None + if payload.get("_scd_auto_prepare_attempted") is True: + return None + allow_prepare, prepare_gate_error = strict_bool_argument( + payload, "allow_sql_saved_state_prepare", method="scd.patch", default=False + ) + if prepare_gate_error: + return prepare_gate_error + if not allow_prepare: + return None + prepared = scd_prepare( + { + "base_id": base_id, + **({"extension": extension} if extension else {}), + "report": report, + "schema": payload.get("schema"), + "mode": "apply", + "allow_sql_saved_state_prepare": True, + "timeout_seconds": timeout_seconds, + } + ) + if prepared.get("status") not in {"applied", "ok", "unchanged"}: + return { + "schema": "onec_scd_patch.v1", "method": "scd.patch", "status": prepared.get("status") or "error", + "base_id": base_id, "extension": extension or None, "report": report, + "schema_name": (inspect.get("scd") or {}).get("name"), "error": "scd_saved_state_prepare_failed", + "prepare_result": prepared, + } + receipt_id = str(prepared.get("prepare_receipt_id") or "").strip().lower() + retry_payload = dict(payload) + retry_payload["_scd_auto_prepare_attempted"] = True + if re.fullmatch(r"[0-9a-f]{32}", receipt_id): + retry_payload["_scd_prepare_receipt_id"] = receipt_id + retried = scd_patch(retry_payload) + # A failed retry must not leave a first-write overlay behind. This cleanup + # deletes only rows named in this call's receipt and still checks their + # hashes, so it cannot remove a concurrent or subsequently changed save. + if re.fullmatch(r"[0-9a-f]{32}", receipt_id) and retried.get("applied") is not True: + retried["prepare_cleanup"] = rollback_saved_state_prepare_receipt( + base_id, receipt_id, timeout_seconds=timeout_seconds + ) + retried["prepare_result"] = prepared + if receipt_id: + retried["prepare_receipt_id"] = receipt_id + return retried + + +def scd_patch(payload: dict[str, Any]) -> dict[str, Any]: + """Create a byte-preserving SCD patch proposal in the appropriate SQL saved state.""" + method = "scd.patch" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + extension = str(payload.get("extension") or "").strip() + report = str(payload.get("report") or payload.get("name") or "").strip() + section = str(payload.get("section") or "").strip() + item_name = str(payload.get("item_name") or payload.get("name_in_schema") or "").strip() + property_name = str(payload.get("property") or "").strip() + value = payload.get("value") + if not report or not section or not item_name or not property_name or not isinstance(value, str): + return invalid_argument(method, "payload", "report, section, item_name, property, and string value are required; extension is optional for a base report.") + timeout_seconds = int(payload.get("timeout_seconds") or 60) + execution_mode = str(payload.get("execution_mode") or "plan").strip().casefold() + if execution_mode not in {"plan", "apply", "apply_and_verify", "apply_and_rollback"}: + return invalid_argument(method, "execution_mode", "execution_mode must be plan, apply, apply_and_verify, or apply_and_rollback.") + allow_write, allow_write_error = strict_bool_argument(payload, "allow_saved_state_write", method=method, default=False) + if allow_write_error: + return allow_write_error + inspect = scd_inspect({ + "base_id": base_id, "extension": extension, "report": report, "schema": payload.get("schema"), + "timeout_seconds": timeout_seconds, + }) + if inspect.get("status") != "ok": + return {"schema": "onec_scd_patch.v1", "method": method, "status": inspect.get("status"), "base_id": base_id, "inspect": inspect} + schema_guid = str((inspect.get("scd") or {}).get("guid") or "") + saved_table = "ConfigCASSave" if extension else "ConfigSave" + file_name = "" + data: bytes | None = None + read_error: dict[str, Any] | None = None + if extension: + saved = extension_objects_find({ + "base_id": base_id, "extension": extension, "guid": schema_guid, "state": "save", + "limit": 2, "include_storage": True, "timeout_seconds": timeout_seconds, + }) + saved_objects = saved.get("objects") if isinstance(saved, dict) and isinstance(saved.get("objects"), list) else [] + if len(saved_objects) == 1: + entries = saved_objects[0].get("manifest_entries") if isinstance(saved_objects[0], dict) and isinstance(saved_objects[0].get("manifest_entries"), list) else [] + entry = next((item for item in entries if isinstance(item, dict) and str(item.get("suffix") or "") == ".0" and str(item.get("cas_key") or "").strip()), None) + if entry: + file_name = str(entry.get("cas_key")) + else: + file_name = str(((inspect.get("scd") or {}).get("payload_route") or {}).get("file_name") or "").strip() + if not file_name: + retried = scd_patch_prepare_and_retry( + payload, base_id=base_id, extension=extension, report=report, + timeout_seconds=timeout_seconds, inspect=inspect, + ) + if retried is not None: + return retried + prepare_payload = {"base_id": base_id, **({"extension": extension} if extension else {}), "report": report, "schema": payload.get("schema"), "mode": "plan", "timeout_seconds": timeout_seconds} + prepare_plan = scd_prepare(prepare_payload) + return { + "schema": "onec_scd_patch.v1", "method": method, "status": "needs_prepare", "base_id": base_id, + "extension": extension, "report": report, "schema_name": (inspect.get("scd") or {}).get("name"), + "diagnostics": {"code": "SCD_SAVED_STATE_SCHEMA_NOT_FOUND", "message": "No unique saved SCD payload exists. The prepare plan distinguishes an empty target from a partial saved-state copy."}, + "prepare_plan": prepare_plan, + "next_call": {"method": "scd.prepare", "params": prepare_payload}, + } + data, _, read_error = read_storage_file_bytes(base_id, saved_table, file_name, timeout_seconds=min(timeout_seconds, 60)) + if read_error or data is None: + retried = scd_patch_prepare_and_retry( + payload, base_id=base_id, extension=extension, report=report, + timeout_seconds=timeout_seconds, inspect=inspect, + ) + if retried is not None: + return retried + prepare_payload = {"base_id": base_id, **({"extension": extension} if extension else {}), "report": report, "schema": payload.get("schema"), "mode": "plan", "timeout_seconds": timeout_seconds} + prepare_plan = scd_prepare(prepare_payload) + return {"schema": "onec_scd_patch.v1", "method": method, "status": "needs_prepare", "base_id": base_id, "extension": extension or None, "report": report, "error": "scd_saved_payload_not_found", "prepare_plan": prepare_plan, "next_call": {"method": "scd.prepare", "params": prepare_payload}} + from parser.scd_payload import plan_scd_scalar_patch + plan = plan_scd_scalar_patch(data, section=section, name=item_name, property_name=property_name, value=value) + post_patch_analysis: dict[str, Any] = {} + if plan.get("status") == "planned" and section == "datasets" and property_name == "query": + from parser.scd_payload import inspect_scd_payload + post_patch = inspect_scd_payload(plan["payload"]) + post_patch_analysis = dict(post_patch.get("analysis") or {}) + missing_parameters = post_patch_analysis.get("referenced_not_declared_in_schema") or [] + if missing_parameters: + return { + "schema": "onec_scd_patch.v1", "method": method, "status": "blocked", "base_id": base_id, + "extension": extension, "report": report, "schema_name": (inspect.get("scd") or {}).get("name"), + "edit": {"section": section, "item_name": item_name, "property": property_name, "old": plan.get("old"), "new": value}, + "diagnostics": {"code": "SCD_QUERY_PARAMETER_NOT_DECLARED", "message": "The proposed query references parameters not declared by this SCD.", "parameters": missing_parameters}, + } + result: dict[str, Any] = { + "schema": "onec_scd_patch.v1", "method": method, "status": "blocked" if not allow_write else plan.get("status"), + "base_id": base_id, "extension": extension, "report": report, "schema_name": (inspect.get("scd") or {}).get("name"), + "write_mode": {"requested": "saved_state", "target_table": saved_table, "sql_write_performed": False, "requires_apply_gate": True}, + "edit": {"section": section, "item_name": item_name, "property": property_name, "old": plan.get("old"), "new": value}, + **({"post_patch_static_analysis": {"query_parameters": post_patch_analysis.get("query_parameter_references") or []}} if post_patch_analysis else {}), + } + if not allow_write: + result["diagnostics"] = {"code": "SCD_WRITE_PLAN_OPT_IN", "message": "No proposal was created. Pass allow_saved_state_write=true to build a reviewed proposal; this still does not write SQL."} + return result + if plan.get("status") != "planned": + result["diagnostics"] = {key: plan.get(key) for key in ("code", "message", "container") if plan.get(key) is not None} + return result + encoded: dict[str, Any] = {"sha1": plan["result_sha1"], "bytes": len(plan["payload"])} + if payload.get("include_payload") is True or execution_mode in {"apply", "apply_and_verify", "apply_and_rollback"}: + encoded["payload_hex"] = plan["payload"].hex() + result["proposal"] = { + "schema": "onec_change_proposal.v1", "method": method, "status": "accepted_for_review", + "source": {"base_id": base_id, "table": saved_table, "file_name": file_name}, + "original": {"sha1": plan["expected_sha1"], "bytes": len(data)}, "encoded": encoded, + "edits": [{"kind": "scd_scalar", "section": section, "item_name": item_name, "property": property_name, "old": plan["old"], "new": value, "mode": "xml_scalar_byte_preserving"}], + } + if execution_mode == "plan": + return result + allow_apply, apply_error = strict_bool_argument(payload, "allow_sql_saved_state_apply", method=method, default=False) + if apply_error: + return apply_error + if not allow_apply: + return invalid_argument(method, "allow_sql_saved_state_apply", "Apply is opt-in; pass allow_sql_saved_state_apply=true after reviewing the proposal.") + if execution_mode == "apply_and_rollback": + allow_rollback, rollback_gate_error = strict_bool_argument( + payload, "allow_sql_saved_state_rollback", method=method, default=False + ) + if rollback_gate_error: + return rollback_gate_error + if not allow_rollback: + return invalid_argument( + method, + "allow_sql_saved_state_rollback", + "apply_and_rollback is opt-in; pass allow_sql_saved_state_rollback=true.", + ) + apply_result = storage_saved_state_apply_proposal({ + **repository_write_context(payload), "base_id": base_id, "ref": f"Report.{report}", "kind": "Report", "name": report, + "allow_sql_saved_state_apply": True, + "proposal": result["proposal"], "timeout_seconds": timeout_seconds, + }) + result["apply_result"] = apply_result + result["applied"] = bool(apply_result.get("applied")) + result["write_mode"]["sql_write_performed"] = result["applied"] + result["status"] = apply_result.get("status") or "error" + semantic_ok: bool | None = None + if result["applied"]: + readback, _, readback_error = read_storage_file_bytes(base_id, saved_table, file_name, timeout_seconds=min(timeout_seconds, 60)) + if not readback_error and readback: + from parser.scd_payload import inspect_scd_payload + decoded_readback = inspect_scd_payload(readback) + found = next((item for item in (decoded_readback.get("sections") or {}).get(section, []) if item.get("name") == item_name), {}) + actual = found.get(property_name) + result["semantic_verification"] = {"status": "ok" if actual == value else "mismatch", "section": section, "item_name": item_name, "property": property_name, "expected": value, "actual": actual} + semantic_ok = actual == value + if execution_mode == "apply_and_verify" and semantic_ok: + result["status"] = "verified" + if result["applied"] and execution_mode == "apply_and_rollback": + if apply_result.get("schema") == "onec_extension_saved_state_pair_apply.v1": + rollback_result = rollback_extension_saved_state_pair( + base_id, apply_result, timeout_seconds=timeout_seconds + ) + else: + backup = apply_result.get("backup") if isinstance(apply_result.get("backup"), dict) else {} + backup_id = str(backup.get("backup_id") or "").strip() + rollback_result = ( + storage_saved_state_rollback( + { + **repository_write_context(payload), + "base_id": base_id, + "allow_sql_saved_state_rollback": True, + "backup_id": backup_id, + "timeout_seconds": timeout_seconds, + } + ) + if backup_id + else {"status": "rollback_unavailable", "applied": False} + ) + result["rollback_result"] = rollback_result + result["rolled_back"] = bool(rollback_result.get("applied")) + prepare_receipt_id = str(payload.get("_scd_prepare_receipt_id") or "").strip().lower() + if result["rolled_back"] and re.fullmatch(r"[0-9a-f]{32}", prepare_receipt_id): + prepare_cleanup = rollback_saved_state_prepare_receipt( + base_id, prepare_receipt_id, timeout_seconds=timeout_seconds + ) + result["prepare_cleanup"] = prepare_cleanup + result["rolled_back"] = bool(prepare_cleanup.get("applied")) + result["status"] = ( + "verified_and_rolled_back" + if semantic_ok is True and result["rolled_back"] + else "applied_rollback_failed" + ) + return result + + +def scd_compare(payload: dict[str, Any]) -> dict[str, Any]: + """Compare active and saved SCD payload semantics, read-only.""" + method = "scd.compare" + base_id_or_error = require_base_id(payload, method) + if isinstance(base_id_or_error, dict): + return base_id_or_error + base_id = base_id_or_error + extension, report = str(payload.get("extension") or "").strip(), str(payload.get("report") or payload.get("name") or "").strip() + if not report: + return invalid_argument(method, "payload", "report is required; extension is optional for a base report.") + timeout_seconds = int(payload.get("timeout_seconds") or 60) + active = scd_inspect({"base_id": base_id, "extension": extension, "report": report, "schema": payload.get("schema"), "timeout_seconds": timeout_seconds}) + if active.get("status") != "ok": + return {"schema": "onec_scd_compare.v1", "method": method, "status": active.get("status"), "base_id": base_id, "active": active} + schema_guid = str((active.get("scd") or {}).get("guid") or "") + saved_table = "ConfigCASSave" if extension else "ConfigSave" + file_name = "" + if extension: + saved = extension_objects_find({"base_id": base_id, "extension": extension, "guid": schema_guid, "state": "save", "limit": 2, "include_storage": True, "timeout_seconds": timeout_seconds}) + objects = saved.get("objects") if isinstance(saved, dict) and isinstance(saved.get("objects"), list) else [] + if len(objects) == 1: + entries = objects[0].get("manifest_entries") if isinstance(objects[0], dict) and isinstance(objects[0].get("manifest_entries"), list) else [] + entry = next((item for item in entries if isinstance(item, dict) and str(item.get("suffix") or "") == ".0" and item.get("cas_key")), None) + if entry: + file_name = str(entry["cas_key"]) + else: + file_name = str(((active.get("scd") or {}).get("payload_route") or {}).get("file_name") or "").strip() + if not file_name: + prepare_payload = {"base_id": base_id, **({"extension": extension} if extension else {}), "report": report, "schema": payload.get("schema"), "mode": "plan", "timeout_seconds": timeout_seconds} + prepare_plan = scd_prepare(prepare_payload) + return {"schema": "onec_scd_compare.v1", "method": method, "status": "needs_prepare", "base_id": base_id, "extension": extension, "report": report, "active": {"schema": active.get("scd"), "container": (active.get("scd") or {}).get("container")}, "prepare_plan": prepare_plan, "next_call": {"method": "scd.prepare", "params": prepare_payload}} + data, _, error = read_storage_file_bytes(base_id, saved_table, file_name, timeout_seconds=min(timeout_seconds, 60)) + if error or data is None: + prepare_payload = {"base_id": base_id, **({"extension": extension} if extension else {}), "report": report, "schema": payload.get("schema"), "mode": "plan", "timeout_seconds": timeout_seconds} + prepare_plan = scd_prepare(prepare_payload) + return {"schema": "onec_scd_compare.v1", "method": method, "status": "needs_prepare", "base_id": base_id, "extension": extension or None, "report": report, "active": {"schema": active.get("scd"), "container": (active.get("scd") or {}).get("container")}, "prepare_plan": prepare_plan, "next_call": {"method": "scd.prepare", "params": prepare_payload}} + from parser.scd_payload import compare_scd_semantics, inspect_scd_payload + saved_decoded = inspect_scd_payload(data) + if saved_decoded.get("status") != "ok": + return {"schema": "onec_scd_compare.v1", "method": method, "status": "partial", "base_id": base_id, "error": "scd_saved_payload_undecodable"} + diff = compare_scd_semantics({"sections": active.get("sections") or {}}, saved_decoded) + return {"schema": "onec_scd_compare.v1", "method": method, "status": diff.get("status"), "base_id": base_id, "extension": extension, "report": report, "schema_name": (active.get("scd") or {}).get("name"), "comparison": diff, "activation_state": "working_not_runtime_applied"} + + +def form_field_lineage(payload: dict[str, Any]) -> dict[str, Any]: + """Find one visual/form field without widening into a global code search.""" + method = "form.field.lineage" + execution_mode = str(payload.get("execution_mode") or "").strip().casefold() + if execution_mode: + if execution_mode != "job": + return invalid_argument(method, "execution_mode", "execution_mode may only be job when provided.", allowed_values=["job"]) + job_payload = dict(payload) + job_payload.pop("execution_mode", None) + return adapter_start_job({ + "method": method, "payload": job_payload, + **({"timeout_seconds": payload.get("timeout_seconds")} if payload.get("timeout_seconds") not in {None, ""} else {}), + }) + field = str(payload.get("field") or "").strip() + if not field: + return invalid_argument(method, "field", "field is required.") + match_mode = str(payload.get("match_mode") or "contains").strip().lower() + if match_mode not in {"contains", "exact", "semantic"}: + return invalid_argument(method, "match_mode", "match_mode must be contains, exact, or semantic.", allowed_values=["contains", "exact", "semantic"]) + analysis_profile = str(payload.get("analysis_profile") or "full").strip().casefold() + if analysis_profile not in {"full", "fast"}: + return invalid_argument(method, "analysis_profile", "analysis_profile must be full or fast.", allowed_values=["full", "fast"]) + include_calculation, include_calculation_error = strict_bool_argument( + payload, "include_calculation", method=method, default=analysis_profile != "fast", + ) + if include_calculation_error: + return include_calculation_error + include_binding_inference, include_binding_inference_error = strict_bool_argument( + payload, "include_binding_inference", method=method, default=True, + ) + if include_binding_inference_error: + return include_binding_inference_error + include_related, include_related_error = strict_bool_argument(payload, "include_related", method=method, default=False) + if include_related_error: + return include_related_error + include_call_chain, include_call_chain_error = strict_bool_argument(payload, "include_call_chain", method=method, default=analysis_profile != "fast") + if include_call_chain_error: + return include_call_chain_error + call_chain_depth, call_chain_depth_error = parse_int_argument( + payload, "call_chain_depth", method=method, default=2, minimum=0, maximum=3, + ) + if call_chain_depth_error: + return call_chain_depth_error + call_timeout, call_timeout_error = parse_int_argument( + payload, "timeout_seconds", method=method, default=30, minimum=1, maximum=30, + ) + if call_timeout_error: + return call_timeout_error + form = str(payload.get("form") or "").strip() + form_title = str(payload.get("form_title") or "").strip() + details = metadata_object_form_details({ + # Field lineage uses decoded semantic fields, not raw per-element + # parameter arrays, which can be very large on managed forms. + **payload, **({"form": form} if form else {}), "include_parameters": False, + "enrich_references": False, + "include_module_text": False, "max_forms": 20, "max_items": 2000, + }) + if details.get("status") != "ok": + return {"schema": "onec_form_field_lineage.v1", "method": method, "status": details.get("status"), "base_id": payload.get("base_id"), "form_details": details} + needle = normalize(field) + matches: list[dict[str, Any]] = [] + matched_forms: set[str] = set() + handler_candidates: dict[str, list[str]] = {} + title_confirmed_forms: set[str] = set() + title_unavailable_forms: set[str] = set() + + def compact_candidate(candidate: dict[str, Any]) -> dict[str, Any]: + semantic = candidate.get("semantic") if isinstance(candidate.get("semantic"), dict) else {} + result = { + key: candidate.get(key) for key in ( + "name", "id", "title", "synonym", "type_name", "data_name", "dynamic_list", + "expression", "query_text", "query_sources", "query_context", "source_references", "query_lineage", "origin_graph", "dynamic_list_binding", "handler", "action", + "binding", + ) if candidate.get(key) not in (None, "") + } | ({"coverage": semantic.get("coverage")} if semantic.get("coverage") else {}) + # Form payload decoder exposes the public logical binding as + # `path_to_data`; normalize it for the lineage API. It is a 1C path, + # not an internal Config/SQL route. + data_path = str(candidate.get("data_path") or candidate.get("path_to_data") or "").strip() + if data_path: + result["data_path"] = data_path + return result + + for item in details.get("forms") or []: + if not isinstance(item, dict): + continue + form_name = item.get("name") + # Some managed forms do not expose a decoded title. Keep the search + # object-scoped in that case instead of incorrectly reporting absence. + decoded_title_identity = normalize(" ".join(str(item.get(key) or "") for key in ("title", "synonym"))) + title_match_status = "not_requested" + if form_title and decoded_title_identity and normalize(form_title) not in decoded_title_identity: + continue + if form_title: + if decoded_title_identity: + title_match_status = "confirmed" + else: + title_match_status = "unavailable" + candidate_sections: list[tuple[str, list[dict[str, Any]]]] = [] + for section in ("elements", "attributes", "commands", "dynamic_lists"): + candidate_sections.append((section, [candidate for candidate in (item.get(section) or []) if isinstance(candidate, dict)])) + dynamic_list_fields: list[dict[str, Any]] = [] + for attribute in item.get("attributes") or []: + if not isinstance(attribute, dict): + continue + list_name = str(attribute.get("name") or "").strip() + settings = attribute.get("dynamic_list_settings") if isinstance(attribute.get("dynamic_list_settings"), dict) else {} + query_text = str(settings.get("query_text") or "").strip() + for dynamic_field in attribute.get("dynamic_list_fields") or []: + if not isinstance(dynamic_field, dict): + continue + candidate = dict(dynamic_field) + candidate.setdefault("name", candidate.get("data_name")) + candidate["dynamic_list"] = list_name + if query_text: + candidate["query_text"] = query_text + try: + from parser.scd_payload import QUERY_SOURCE_BINDING_RE + source_bindings: dict[str, str] = {} + source_aliases: dict[str, str] = {} + for found in QUERY_SOURCE_BINDING_RE.finditer(query_text): + source = str(found.group(1)).strip() + alias = str(found.group(2) or source).strip() + source_bindings[normalize(alias)] = source + source_aliases[normalize(alias)] = alias + # QUERY_SOURCE_BINDING_RE intentionally stays shared + # and conservative. Dynamic-list lineage additionally + # accepts the parameter list of a virtual table, e.g. + # ``InformationRegister.Prices.SliceLast(&Date) AS P``. + virtual_source_arguments: dict[str, str] = {} + source_with_arguments = re.compile( + r"(?is)\b(?:ИЗ|СОЕДИНЕНИЕ)\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" + r"\s*\((?P[^()]*)\)\s+КАК\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)" + ) + for source_match in source_with_arguments.finditer(query_text): + source = str(source_match.group("source") or "").strip() + alias = str(source_match.group("alias") or "").strip() + if not source or not alias: + continue + source_bindings[normalize(alias)] = source + source_aliases[normalize(alias)] = alias + virtual_source_arguments[normalize(alias)] = str(source_match.group("arguments") or "").strip() + temporary_field_bindings: dict[tuple[str, str], tuple[str, str]] = {} + temporary_tables: dict[str, list[dict[str, str]]] = {} + temporary_rule = re.compile( + r"(?is)\bВЫБРАТЬ\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*\." + r"[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+КАК\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+ПОМЕСТИТЬ\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+ИЗ\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" + r"\s+КАК\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)" + ) + for temporary_match in temporary_rule.finditer(query_text): + expression_alias, expression_field = str(temporary_match.group("expression")).split(".", 1) + source_alias = str(temporary_match.group("source_alias") or "") + # Do not infer a source if projection points to a + # different alias than the declared source. + if normalize(expression_alias) != normalize(source_alias): + continue + temporary_name = str(temporary_match.group("temporary") or "") + output_field = str(temporary_match.group("output") or "") + source = str(temporary_match.group("source") or "") + temporary_field_bindings[(normalize(temporary_name), normalize(output_field))] = (source, expression_field) + temporary_tables.setdefault(normalize(temporary_name), []).append({ + "field": output_field, "source": source, "source_field": expression_field, + }) + temporary_source_aliases: dict[str, str] = {} + for temporary_source in re.finditer( + r"(?is)\bИЗ\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+КАК\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)", query_text, + ): + temporary_name = str(temporary_source.group("temporary") or "") + if normalize(temporary_name) in temporary_tables: + temporary_source_aliases[normalize(str(temporary_source.group("alias") or ""))] = temporary_name + # Resolve the intentionally narrow, common case of a + # single derived table: ``FROM (SELECT A.X AS X FROM + # Catalog.A AS A) AS T``. It is not a general 1C + # query parser: nested derived tables, joins and + # expressions remain explicitly partial below. + derived_field_bindings: dict[tuple[str, str], tuple[str, str]] = {} + derived_rule = re.compile( + r"(?is)\bИЗ\s*\(\s*\bВЫБРАТЬ\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\." + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+КАК\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+" + r"\bИЗ\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" + r"(?:\s+КАК\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*))?\s*\)\s+КАК\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)" + ) + for derived in derived_rule.finditer(query_text): + projection_alias = str(derived.group("projection") or "").strip() + source_alias = str(derived.group("source_alias") or "").strip() + # A mismatch cannot be proven from this compact + # grammar, therefore do not infer it. + if source_alias and normalize(projection_alias) != normalize(source_alias): + continue + derived_field_bindings[( + normalize(str(derived.group("derived_alias") or "")), + normalize(str(derived.group("output") or "")), + )] = (str(derived.group("source") or ""), str(derived.group("field") or "")) + candidate["query_sources"] = [ + {"object_ref": source, "alias": source_aliases[alias]} + for alias, source in source_bindings.items() + ] + # Keep query constraints next to field provenance: a + # price/source can be correct only under the dynamic + # list's parameter, filter and ordering context. We + # intentionally return text clauses rather than + # executing or normalising 1C query semantics. + query_context: dict[str, Any] = {} + parameters = sorted(set(re.findall(r"&([A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)", query_text)), key=normalize) + if parameters: + query_context["parameters"] = parameters + parameter_bindings: list[dict[str, Any]] = [] + for parameter in parameters: + candidates: list[dict[str, Any]] = [] + for attribute in item.get("attributes") or []: + if not isinstance(attribute, dict): + continue + attribute_name = str(attribute.get("name") or "").strip() + attribute_path = str(attribute.get("data_path") or attribute.get("path_to_data") or "").strip() + if normalize(attribute_name) == normalize(parameter) or normalize(attribute_path.rsplit(".", 1)[-1]) == normalize(parameter): + candidates.append({ + "kind": "form_attribute", "name": attribute_name, + **({"data_path": attribute_path} if attribute_path else {}), + }) + for element in item.get("elements") or []: + if not isinstance(element, dict): + continue + element_name = str(element.get("name") or "").strip() + element_path = str(element.get("data_path") or element.get("path_to_data") or "").strip() + if normalize(element_name) == normalize(parameter) or normalize(element_path.rsplit(".", 1)[-1]) == normalize(parameter): + candidates.append({ + "kind": "form_element", "name": element_name, + **({"data_path": element_path} if element_path else {}), + }) + if candidates: + parameter_bindings.append({ + "parameter": parameter, "status": "inferred", + "source": "decoded_name_or_data_path", "candidates": candidates, + }) + if parameter_bindings: + query_context["parameter_bindings"] = parameter_bindings + for clause_name, start_pattern, end_pattern in ( + ("filter", r"\bГДЕ\b", r"\b(?:СГРУППИРОВАТЬ\s+ПО|УПОРЯДОЧИТЬ\s+ПО|ИТОГИ|ОБЪЕДИНИТЬ)\b|;"), + ("group_by", r"\bСГРУППИРОВАТЬ\s+ПО\b", r"\b(?:УПОРЯДОЧИТЬ\s+ПО|ИТОГИ|ОБЪЕДИНИТЬ)\b|;"), + ("order_by", r"\bУПОРЯДОЧИТЬ\s+ПО\b", r"\b(?:ИТОГИ|ОБЪЕДИНИТЬ)\b|;"), + ): + clause_match = re.search( + rf"(?is){start_pattern}\s*(.*?)(?={end_pattern}|\Z)", query_text, + ) + if clause_match and clause_match.group(1).strip(): + query_context[clause_name] = clause_match.group(1).strip() + joins: list[dict[str, Any]] = [] + join_pattern = re.compile( + r"(?is)\b(?P(?:ЛЕВОЕ|ПРАВОЕ|ПОЛНОЕ|ВНУТРЕННЕЕ)\s+)?" + r"СОЕДИНЕНИЕ\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" + r"(?:\s*\([^()]*\))?(?:\s+КАК\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*))?\s+ПО\s+" + r"(?P.*?)(?=\b(?:ЛЕВОЕ|ПРАВОЕ|ПОЛНОЕ|ВНУТРЕННЕЕ)?\s*СОЕДИНЕНИЕ\b|\b(?:ГДЕ|СГРУППИРОВАТЬ|УПОРЯДОЧИТЬ|ИТОГИ|ОБЪЕДИНИТЬ)\b|;|\Z)" + ) + for join in join_pattern.finditer(query_text): + joins.append({ + "kind": " ".join(str(join.group("join_kind") or "inner").split()).casefold(), + "source": str(join.group("source") or "").strip(), + **({"alias": str(join.group("alias")).strip()} if join.group("alias") else {}), + "condition": str(join.group("condition") or "").strip(), + }) + if joins: + query_context["joins"] = joins + virtual_sources_by_source: dict[str, dict[str, Any]] = {} + virtual_names = {"срезпоследних", "остатки", "обороты", "остаткииобороты"} + for alias, source in source_bindings.items(): + parts = source.split(".") + if len(parts) < 3 or normalize(parts[-1]) not in virtual_names: + continue + if normalize(parts[0]) not in { + "регистрсведений", "регистрнакопления", "informationregister", "accumulationregister", + }: + continue + virtual_sources_by_source[normalize(source)] = { + "alias": source_aliases.get(alias, alias), "source": source, + "register": ".".join(parts[:-1]), "virtual_table": parts[-1], + **({"arguments": virtual_source_arguments[alias]} if virtual_source_arguments.get(alias) else {}), + } + virtual_sources = list(virtual_sources_by_source.values()) + if virtual_sources: + query_context["virtual_sources"] = virtual_sources + if temporary_tables: + query_context["temporary_tables"] = [ + {"name": next((name for name in temporary_source_aliases.values() if normalize(name) == temporary_name), temporary_name), + "fields": fields} + for temporary_name, fields in temporary_tables.items() + ] + if query_context: + query_context["status"] = "static_query_context" + candidate["query_context"] = query_context + output_name = str(candidate.get("name") or candidate.get("data_name") or "").strip() + output_query_text = query_text.rsplit(";", 1)[-1] if temporary_tables and ";" in query_text else query_text + output_rule = re.compile( + r"(?is)(?:\bВЫБРАТЬ\b|,)\s*(?P.*?)\s+КАК\s+" + + re.escape(output_name) + r"(?=\s*,|\s+ИЗ\b|$)" + ).search(output_query_text) if output_name else None + if output_rule: + expression = output_rule.group("expression").strip() + candidate["expression"] = expression + references = [] + seen_references: set[tuple[str, str]] = set() + for reference in re.finditer(r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\.(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)", expression): + alias = reference.group("alias") + field_name = reference.group("field") + reference_key = (normalize(alias), normalize(field_name)) + if reference_key in seen_references: + continue + seen_references.add(reference_key) + source = source_bindings.get(normalize(alias)) + item_reference: dict[str, Any] = { + "alias": alias, "field": field_name, + } + if source: + item_reference.update({"source": source, "public_path": f"{source}.{field_name}"}) + else: + derived_source = derived_field_bindings.get((normalize(alias), normalize(field_name))) + if derived_source and derived_source[0] and derived_source[1]: + item_reference.update({ + "source": derived_source[0], "public_path": f"{derived_source[0]}.{derived_source[1]}", + "derived_from": {"alias": alias, "field": field_name}, + "resolution": "derived_table_projection", + }) + else: + temporary_name = temporary_source_aliases.get(normalize(alias)) + temporary_source = temporary_field_bindings.get((normalize(temporary_name), normalize(field_name))) if temporary_name else None + if temporary_source and temporary_source[0] and temporary_source[1]: + item_reference.update({ + "source": temporary_source[0], "public_path": f"{temporary_source[0]}.{temporary_source[1]}", + "derived_from": {"temporary_table": temporary_name, "field": field_name}, + "resolution": "temporary_table_projection", + }) + references.append(item_reference) + if references: + candidate["source_references"] = references + unresolved_aliases = sorted({ + str(reference.get("alias") or "") + for reference in references + if not reference.get("source") + } - {""}) + complex_constructs: list[str] = [] + # This is deliberately a small, transparent + # parser. It can prove simple ``source.field AS + # output`` bindings, but must not claim that a + # derived table, union, package query or virtual + # table has been followed to its physical source. + for token, code in ( + (r"\bОБЪЕДИНИТЬ\b|\bUNION\b", "union"), + (r"\bПОМЕСТИТЬ\b|\bINTO\b", "temporary_table"), + (r"\bВЫБРАТЬ\b|\bSELECT\b", "nested_query"), + (r"\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*\s*\(", "virtual_or_function_source"), + ): + # One SELECT is the outer query itself; two + # or more indicate a nested data source. + if code == "nested_query": + if len(re.findall(token, query_text, flags=re.IGNORECASE)) > 1: + complex_constructs.append(code) + elif re.search(token, query_text, flags=re.IGNORECASE): + complex_constructs.append(code) + diagnostic_codes: list[str] = [] + if unresolved_aliases: + diagnostic_codes.append("source_alias_unresolved") + if complex_constructs: + diagnostic_codes.append("complex_query_not_fully_traced") + if not references: + diagnostic_codes.append("expression_has_no_direct_source_reference") + direct_reference = bool(re.fullmatch( + r"\s*[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*\." + r"[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*\s*", expression, + )) + if not direct_reference: + # Formula inputs are statically identified, + # but the SQL-only adapter neither evaluates + # 1C query expressions nor proves the value + # for a particular row. + diagnostic_codes.append("calculated_expression") + union_branches: list[dict[str, Any]] = [] + union_parts = re.split(r"(?is)\b(?:ОБЪЕДИНИТЬ(?:\s+ВСЕ)?|UNION(?:\s+ALL)?)\b", query_text) + if len(union_parts) > 1 and output_name: + branch_rule = re.compile( + r"(?is)\bВЫБРАТЬ\s+(?P.*?)\s+КАК\s+" + + re.escape(output_name) + + r"(?=\s*,|\s+ИЗ\b)\s+ИЗ\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" + r"(?:\s*(?:\([^()]*\))?)\s+(?:КАК\s+)?(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)" + ) + for branch_number, branch_text in enumerate(union_parts, start=1): + branch_match = branch_rule.search(branch_text) + if not branch_match: + union_branches.append({ + "branch": branch_number, "status": "partial", + "diagnostics": ["union_branch_output_not_parsed"], + }) + continue + branch_expression = str(branch_match.group("expression") or "").strip() + branch_source = str(branch_match.group("source") or "").strip() + branch_alias = str(branch_match.group("alias") or "").strip() + branch_references = [] + for branch_ref in re.finditer(r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\.(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)", branch_expression): + if normalize(branch_ref.group("alias")) != normalize(branch_alias): + continue + field_name = branch_ref.group("field") + branch_references.append({ + "alias": branch_alias, "field": field_name, + "source": branch_source, "public_path": f"{branch_source}.{field_name}", + }) + union_branches.append({ + "branch": branch_number, + "status": "confirmed" if branch_references else "partial", + "expression": branch_expression, + "source_references": branch_references, + **({"diagnostics": ["union_branch_has_no_direct_source_reference"]} if not branch_references else {}), + }) + candidate["query_lineage"] = { + "status": "confirmed" if not diagnostic_codes else "partial", + "parser": "simple_select_alias_v1", + "output_field": output_name, + "expression": expression, + "binding_kind": "direct_field" if direct_reference else "calculated_expression", + **({"union_branches": union_branches} if union_branches else {}), + **({"unresolved_aliases": unresolved_aliases} if unresolved_aliases else {}), + **({"complex_constructs": complex_constructs} if complex_constructs else {}), + **({"diagnostics": diagnostic_codes} if diagnostic_codes else {}), + } + output_id = f"query_output:{candidate.get('dynamic_list') or 'dynamic_list'}.{output_name}" + field_id = f"form_field:{candidate.get('data_path') or candidate.get('path_to_data') or output_name}" + graph_nodes: list[dict[str, Any]] = [ + {"id": field_id, "kind": "form_dynamic_list_field", "name": output_name, + **({"data_path": candidate.get("data_path") or candidate.get("path_to_data")} if candidate.get("data_path") or candidate.get("path_to_data") else {})}, + {"id": output_id, "kind": "query_output", "name": output_name, "expression": expression}, + ] + graph_edges: list[dict[str, Any]] = [{ + "from": field_id, "to": output_id, "kind": "dynamic_list_column_binding", + }] + graph_references = list(references) + for branch in union_branches: + if isinstance(branch, dict): + graph_references.extend(item for item in (branch.get("source_references") or []) if isinstance(item, dict)) + seen_graph_sources: set[str] = set() + for source_reference in graph_references: + public_path = str(source_reference.get("public_path") or "").strip() + if not public_path or public_path in seen_graph_sources: + continue + seen_graph_sources.add(public_path) + source_id = f"source:{public_path}" + graph_nodes.append({ + "id": source_id, "kind": "metadata_field", "public_path": public_path, + "source": source_reference.get("source"), "field": source_reference.get("field"), + }) + graph_edges.append({ + "from": output_id, "to": source_id, "kind": "query_source_reference", + **({"resolution": source_reference.get("resolution")} if source_reference.get("resolution") else {}), + }) + candidate["origin_graph"] = { + "status": candidate["query_lineage"]["status"], "nodes": graph_nodes, "edges": graph_edges, + } + else: + candidate["query_lineage"] = { + "status": "partial", "parser": "simple_select_alias_v1", + "output_field": output_name, + "diagnostics": ["output_field_expression_not_parsed"], + } + except Exception: + # Form lineage remains available even for a malformed + # query text or an unavailable optional parser import. + candidate["expression"] = query_text + candidate["query_lineage"] = { + "status": "partial", "parser": "simple_select_alias_v1", + "diagnostics": ["query_parse_failed"], + } + dynamic_list_fields.append(candidate) + if dynamic_list_fields: + candidate_sections.append(("dynamic_list_fields", dynamic_list_fields)) + # A managed-form control often has a human-facing name different + # from the dynamic-list output alias (for example + # ``Себестоимость`` -> ``Список.FoodCost``). The decoded data + # path is a direct static binding, so preserve it instead of + # relying on a fragile name comparison. + fields_by_path: dict[str, dict[str, Any]] = {} + for dynamic_field in dynamic_list_fields: + list_name = str(dynamic_field.get("dynamic_list") or "").strip() + field_name = str(dynamic_field.get("name") or dynamic_field.get("data_name") or "").strip() + field_path = str(dynamic_field.get("data_path") or dynamic_field.get("path_to_data") or "").strip() + for path in (field_path, f"{list_name}.{field_name}" if list_name and field_name else ""): + if path: + fields_by_path[normalize(path)] = dynamic_field + for element in item.get("elements") or []: + if not isinstance(element, dict): + continue + element_path = str(element.get("data_path") or element.get("path_to_data") or "").strip() + dynamic_field = fields_by_path.get(normalize(element_path)) if element_path else None + if not dynamic_field: + continue + dynamic_graph = dynamic_field.get("origin_graph") if isinstance(dynamic_field.get("origin_graph"), dict) else None + visual_origin_graph: dict[str, Any] | None = None + if dynamic_graph: + graph_nodes = [dict(node) for node in (dynamic_graph.get("nodes") or []) if isinstance(node, dict)] + graph_edges = [dict(edge) for edge in (dynamic_graph.get("edges") or []) if isinstance(edge, dict)] + dynamic_field_node = next((node for node in graph_nodes if node.get("kind") == "form_dynamic_list_field"), None) + if dynamic_field_node and dynamic_field_node.get("id"): + visual_node_id = f"form_element:{element.get('id') or element.get('name') or element_path}" + graph_nodes.insert(0, { + "id": visual_node_id, "kind": "form_element", "name": element.get("name"), + "data_path": element_path, + }) + graph_edges.insert(0, { + "from": visual_node_id, "to": dynamic_field_node["id"], + "kind": "visual_element_dynamic_list_binding", + "evidence": "decoded_form_data_path", + }) + visual_origin_graph = {"status": dynamic_graph.get("status"), "nodes": graph_nodes, "edges": graph_edges} + element["dynamic_list_binding"] = { + "status": "confirmed", + "source": "decoded_form_data_path", + "data_path": element_path, + "dynamic_list": dynamic_field.get("dynamic_list"), + "field": dynamic_field.get("name") or dynamic_field.get("data_name"), + **({"query_lineage": dynamic_field["query_lineage"]} if isinstance(dynamic_field.get("query_lineage"), dict) else {}), + **({"origin_graph": visual_origin_graph} if visual_origin_graph else {}), + } + + for section, candidates in candidate_sections: + for candidate in candidates: + if not include_related and str(candidate.get("type_name") or "").casefold() in { + "контекстное меню", "расширенная подсказка", + }: + continue + identity_values = [str(candidate.get(key) or "") for key in ( + "name", "title", "synonym", "data_name", "data_path", "path_to_data", + )] + exact_match = any(normalize(value) == needle for value in identity_values if value) + haystack = " ".join(identity_values + [str(candidate.get(key) or "") for key in ("expression", "query_text", "handler")]) + type_name = str(candidate.get("type_name") or "").casefold() + semantic_match = ( + needle in normalize(" ".join(identity_values)) + and section not in {"commands"} + and "кнопка" not in type_name + and "командн" not in type_name + ) + matched = exact_match if match_mode == "exact" else semantic_match if match_mode == "semantic" else needle in normalize(haystack) + if matched: + if title_match_status == "confirmed": + title_confirmed_forms.add(str(form_name or "")) + elif title_match_status == "unavailable": + title_unavailable_forms.add(str(form_name or "")) + matched_forms.add(form_name) + compact = compact_candidate(candidate) + matches.append({ + "form": form_name, "section": section, "item": compact, + "match_by": "exact" if exact_match else "semantic" if semantic_match and match_mode == "semantic" else "contains", + "evidence": "decoded_dynamic_list_field" if section == "dynamic_list_fields" else "decoded_form_payload", + **({"form_title_match": title_match_status} if form_title else {}), + }) + if section == "commands": + handler = str(candidate.get("handler") or candidate.get("action") or "").strip() + if handler: + handler_candidates.setdefault(form_name, []).append(handler) + + binding_inference_count = 0 + if bool(include_binding_inference) and matches: + needs_name_inference = any( + match.get("section") == "elements" + and isinstance(match.get("item"), dict) + and not str(match["item"].get("data_path") or "").strip() + for match in matches + ) + object_fields = metadata_object_attributes({**payload, "only": "all", "include_storage": False}) if needs_name_inference else {} + tabular_sections = object_fields.get("tabular_sections") if isinstance(object_fields.get("tabular_sections"), list) else [] + object_attributes = object_fields.get("attributes") if isinstance(object_fields.get("attributes"), list) else [] + for match in matches: + if match.get("section") != "elements" or not isinstance(match.get("item"), dict): + continue + item = match["item"] + element_name = str(item.get("name") or "") + data_path = str(item.get("data_path") or "").strip() + if data_path: + if data_path.casefold().startswith("объект."): + item["binding"] = { + "status": "confirmed", "object_member_ref": data_path, + "source": "decoded_form_data_path", + } + binding_inference_count += 1 + continue + if not element_name: + continue + for tabular_section in tabular_sections: + if not isinstance(tabular_section, dict): + continue + section_name = str(tabular_section.get("name") or "") + if not section_name or not normalize(element_name).startswith(normalize(section_name)): + continue + column_name = element_name[len(section_name):] + column = next((value for value in (tabular_section.get("columns") or []) if isinstance(value, dict) + and normalize(str(value.get("name") or "")) == normalize(column_name)), None) + if column: + item["binding"] = { + "status": "inferred_confirmed", "object_member_ref": f"Объект.{section_name}.{column.get('name')}", + "member": { + "name": column.get("name"), "synonym": column.get("synonym"), + "type": column.get("type"), + }, + "source": "element_name + decoded_tabular_section", + } + binding_inference_count += 1 + break + if item.get("binding"): + continue + object_attribute = next((value for value in object_attributes if isinstance(value, dict) + and normalize(str(value.get("name") or "")) == normalize(element_name)), None) + if object_attribute: + item["binding"] = { + "status": "inferred_confirmed", "object_member_ref": f"Объект.{object_attribute.get('name')}", + "member": { + "name": object_attribute.get("name"), "synonym": object_attribute.get("synonym"), + "type": object_attribute.get("type"), + }, + "source": "element_name + decoded_object_attribute", + } + binding_inference_count += 1 + + call_chains: list[dict[str, Any]] = [] + if bool(include_call_chain) and matches: + modules_result = metadata_object_modules({**payload, "include_storage": True}) + modules = modules_result.get("modules") if isinstance(modules_result.get("modules"), list) else [] + for form_name in sorted(matched_forms): + form_module = next((module for module in modules if isinstance(module, dict) + and module.get("kind") == "form_module" + and isinstance(module.get("form"), dict) + and normalize(str(module["form"].get("name") or "")) == normalize(form_name)), None) + if not form_module: + continue + module_ref = str(form_module.get("module_id") or "") + if not module_ref: + continue + # A command matching the requested field is the most explicit link + # between visual control and BSL. Resolve only its first handler. + handlers = handler_candidates.get(form_name) or [] + if not handlers: + continue + handler = handlers[0] + chain = diagnostics_call_chain({ + "base_id": payload.get("base_id"), "module_ref": module_ref, + "entry_method": handler, "depth": int(call_chain_depth), + "max_nodes": 20, "timeout_seconds": int(call_timeout), + }) + call_chains.append({"form": form_name, "entry_handler": handler, "chain": chain}) + calculation: dict[str, Any] | None = None + # A concise calculation explanation is more useful than raw BSL when the + # form field is populated by the standard product-cost routine. The + # routine is read through the already supplied object selector only. + is_cost_field = "себестоимость" in needle + is_price_field = "ценапродажи" in needle and not is_cost_field + if bool(include_calculation) and (is_cost_field or is_price_field) and matches: + calculation_read = code_read({ + **payload, "routine_name": "ОбновитьСебестоимостьПродукцииСПодготовленнымМВТ", + "include_text": True, "include_routines": False, "include_summary": False, + "max_chars": 100000, + }) + calculation_text = str(calculation_read.get("text") or "") + if calculation_read.get("status") in {"ok", "summary", "text"} and calculation_text: + normalized_lines = [line.strip().lstrip("|").strip() for line in calculation_text.splitlines()] + marker = next((index for index, line in enumerate(normalized_lines) if "КАК Себестоимость" in line), -1) + formula_lines: list[str] = [] + if marker >= 0: + start = max(0, marker - 18) + formula_lines = [line for line in normalized_lines[start: marker + 1] if line] + price_match = re.search(r"([\wА-Яа-яЁё]+)\.(ПолучитьЦеныПоТаблицеЦен)\s*\(", calculation_text) + price_source: dict[str, Any] | None = None + # The product-cost routine delegates price lookup through the UOP + # helper into the standard pricing module. Both are configuration + # BSL modules, so the register can be identified without runtime + # execution or a global search. + if price_match: + pricing_bridge = code_read({ + "base_id": payload.get("base_id"), "ref": f"CommonModule.{price_match.group(1)}", + "routine_name": price_match.group(2), "include_text": True, "include_routines": False, + "include_summary": False, "max_chars": 100000, + }) + bridge_text = str(pricing_bridge.get("text") or "") + standard_call = re.search(r"ЦенообразованиеСервер\.(ПолучитьЦеныТабличнойЧастиПоВидуЦен)\s*\(", bridge_text) + if standard_call: + pricing_read = code_read({ + "base_id": payload.get("base_id"), "ref": "CommonModule.ЦенообразованиеСервер", + "routine_name": standard_call.group(1), "include_text": True, "include_routines": False, + "include_summary": False, "max_chars": 200000, + }) + pricing_text = str(pricing_read.get("text") or "") + register_match = re.search(r"РегистрСведений\.([\wА-Яа-яЁё]+)\.СрезПоследних", pricing_text) + if register_match: + extension_filter: dict[str, Any] | None = None + uop_filter_read = code_read({ + "base_id": payload.get("base_id"), "ref": "CommonModule.УОП_ЦенообразованиеСервер", + "routine_name": "ДополнитьТекстЗапросаЦеныНоменклатуры", "include_text": True, + "include_routines": False, "include_summary": False, "max_chars": 30000, + }) + uop_filter_text = str(uop_filter_read.get("text") or "") + if "УОП_ПодразделениеЦен" in uop_filter_text: + extension_filter = { + "field": "УОП_ПодразделениеЦен", + "when_empty": "УОП_ПодразделениеЦен = ЗНАЧЕНИЕ(Справочник.СтруктурныеЕдиницы.ПустаяСсылка)", + "when_supplied": "УОП_ПодразделениеЦен В (&УОП_ПодразделениеЦен)", + "parameter": "УОП_ПодразделениеЦен", + "evidence": "УОП_ЦенообразованиеСервер.ДополнитьТекстЗапросаЦеныНоменклатуры", + } + price_source = { + "status": "partial", + "register": f"InformationRegister.{register_match.group(1)}", + "access": "СрезПоследних", + "effective_date": "ПолучитьДатуОбработкиЦен(ЭтоНовыйОбъект, СтруктураДанных.Дата)", + "filters": ["ВидЦен = &ВидЦен", "Номенклатура", "Характеристика (when present)"], + **({"extension_filter": extension_filter} if extension_filter else {}), + "evidence": "ЦенообразованиеСервер.ПолучитьЦеныТабличнойЧастиПоВидуЦен", + } + calculation = { + "status": "partial", + "target": "Продукция.Себестоимость" if is_cost_field else "Продукция/ВидыКоличеств.ЦенаПродажи", + "routine": "ОбновитьСебестоимостьПродукцииСПодготовленнымМВТ", + "formula": ( + "SUM((Стоимость, иначе СтоимостьРучная) × КоличествоИнгредиентаНорма × коэффициент единицы продукции / коэффициент единицы рецептуры)" + if is_cost_field else + "ЦенаВЕИ = Цена из регистра / коэффициент единицы цены × коэффициент единицы продукции; итог распределяется в Продукция и ВидыКоличеств" + ), + "formula_evidence": formula_lines if is_cost_field else [ + line for line in normalized_lines if "ЦенаВЕИИнгредиента" in line or "ЗаполнитьСтоимость(" in line + ][:12], + **({"ingredient_cost_sources": ["втПроизводство.СтоимостьИнгредиента", "втЗамены.СтоимостьЗамены", "ручные стоимости"]} if is_cost_field else {}), + "sales_price_provider": { + "routine": f"{price_match.group(1)}.{price_match.group(2)}" if price_match else None, + "context": ["Организация", "Подразделение", "ДатаПлана", "Номенклатура", "Характеристика", "ВидЦенПродаж"], + **({"source": price_source} if price_source else {}), + }, + "assignment_evidence": ( + "НайденныеСтроки[0].Себестоимость = ВыборкаДетальныеЗаписи.Себестоимость" + if is_cost_field else + 'ЗаполнитьСтоимость(..., "Продукция", ..., "ВидыКоличеств", "НомерСтрокиВида", "ЦенаПродажи")' + ), + "limitation": "Статически подтверждены регистр и условия отбора. Адаптер не исполняет 1С-запрос и не подменяет фактические данные расчётом.", + } + exact_count = sum(1 for item in matches if item.get("match_by") == "exact") + disambiguation_candidates: list[dict[str, Any]] = [] + seen_disambiguation_candidates: set[tuple[str, str]] = set() + for item in matches: + candidate_item = item.get("item") if isinstance(item.get("item"), dict) else {} + field_name = str(candidate_item.get("name") or "") + form_name = str(item.get("form") or "") + if not field_name: + continue + # A form-scoped repeat call does not need separate variants for the + # same visual name found in several decoded sections. + candidate_key = (form_name, field_name) + if candidate_key in seen_disambiguation_candidates: + continue + seen_disambiguation_candidates.add(candidate_key) + disambiguation_candidates.append({ + "form": item.get("form"), "section": item.get("section"), + "field": field_name, "title": candidate_item.get("title"), + }) + selector_params = ( + {"ref": payload.get("ref")} + if payload.get("ref") else + { + key: payload[key] + for key in ("kind", "name", "guid", "object_type", "object_name", "object_guid") + if payload.get(key) not in (None, "") + } + ) + recommended_calls = [ + { + "method": method, + "params": { + "base_id": payload.get("base_id"), **selector_params, + **({"form": candidate["form"]} if candidate.get("form") else {}), + "field": candidate["field"], "match_mode": "exact", + "include_related": bool(include_related), + "include_binding_inference": bool(include_binding_inference), + "include_call_chain": bool(include_call_chain), + "include_calculation": bool(include_calculation), + }, + } + for candidate in disambiguation_candidates[:20] + ] + agent_paths: list[dict[str, Any]] = [] + seen_agent_paths: set[tuple[str, str, str, str]] = set() + for match in matches: + item = match.get("item") if isinstance(match.get("item"), dict) else {} + form_name = str(match.get("form") or "") + field_name = str(item.get("name") or field) + section = str(match.get("section") or "") + binding = item.get("binding") if isinstance(item.get("binding"), dict) else {} + references = item.get("source_references") if isinstance(item.get("source_references"), list) else [] + if not references and isinstance(item.get("dynamic_list_binding"), dict): + references = (item["dynamic_list_binding"].get("query_lineage") or {}).get("source_references") or [] + public_paths = sorted({ + str(reference.get("public_path") or "").strip() + for reference in references if isinstance(reference, dict) and str(reference.get("public_path") or "").strip() + }) + if not public_paths and binding.get("object_member_ref"): + public_paths = [str(binding["object_member_ref"])] + if not public_paths: + public_paths = [""] + query_lineage = item.get("query_lineage") if isinstance(item.get("query_lineage"), dict) else {} + for public_path in public_paths: + path_key = (form_name, section, field_name, public_path) + if path_key in seen_agent_paths: + continue + seen_agent_paths.add(path_key) + visual_container = ( + str(item.get("type_name") or "").casefold() + in {"страница", "группа", "группастраниц", "commandbar", "командная панель"} + and not item.get("data_path") and not item.get("expression") and not references and not binding + ) + action_binding = ( + section == "commands" + or "кнопка" in str(item.get("type_name") or "").casefold() + or "командн" in str(item.get("type_name") or "").casefold() + ) + status = "container_only" if visual_container else "confirmed" if public_path and ( + query_lineage.get("status") == "confirmed" or binding.get("status") in {"confirmed", "inferred_confirmed"} + ) else "partial" + limitations = [] + if visual_container: + limitations.append("The exact name matched a visual container, not a data-bound field.") + if query_lineage.get("diagnostics"): + limitations.append(", ".join(query_lineage["diagnostics"])) + agent_paths.append({ + "status": status, "form": form_name, "section": section, + "field": field_name, + "path_kind": "action" if action_binding else "data", + **({"data_path": item.get("data_path")} if item.get("data_path") else {}), + **({"expression": item.get("expression")} if item.get("expression") else {}), + **({"source": public_path} if public_path else {}), + **({"binding_source": binding.get("source")} if binding.get("source") else {}), + **({"limitation": "; ".join(limitations)} if limitations else {}), + }) + has_confirmed_data_path = any(path.get("status") == "confirmed" and path.get("path_kind") == "data" for path in agent_paths) + has_partial_path = any(path.get("status") == "partial" for path in agent_paths) + agent_summary = { + # A page/group match is useful navigation evidence but must not turn a + # successfully resolved data field into a partial lineage result. + "status": "confirmed" if has_confirmed_data_path and not has_partial_path else "partial" if agent_paths else "not_found", + "confirmed_paths": [path for path in agent_paths if path.get("status") == "confirmed"], + "confirmed_data_paths": [path for path in agent_paths if path.get("status") == "confirmed" and path.get("path_kind") == "data"], + "confirmed_action_paths": [path for path in agent_paths if path.get("status") == "confirmed" and path.get("path_kind") == "action"], + "partial_paths": [path for path in agent_paths if path.get("status") == "partial"], + "container_matches": [path for path in agent_paths if path.get("status") == "container_only"], + "rule": "Only static decoded bindings and direct query references are confirmed; formulas and runtime values are never evaluated.", + } + return { + "schema": "onec_form_field_lineage.v2", "method": method, + "status": "ok" if matches else "not_found", "base_id": payload.get("base_id"), + "ref": payload.get("ref"), "field": field, "match_mode": match_mode, + "analysis_profile": analysis_profile, "matches": matches, + "match_summary": { + "count": len(matches), "exact_count": exact_count, + "binding_inference_count": binding_inference_count, + "ambiguous": len(matches) > 1, + **({"recommended_match_mode": "exact"} if len(matches) > 1 and match_mode != "exact" else {}), + **({"candidates": disambiguation_candidates[:50]} if len(matches) > 1 else {}), + # Exact field names can still occur in several managed forms. + # Always provide a form-scoped follow-up when the result is ambiguous. + **({"next_calls": recommended_calls} if len(matches) > 1 else {}), + }, + **({"form_title_filter": { + "requested": form_title, + "status": "partial" if title_unavailable_forms else "confirmed", + "confirmed_forms": sorted(name for name in title_confirmed_forms if name), + "unavailable_forms": sorted(name for name in title_unavailable_forms if name), + **({"limitation": "Some matched forms have no statically decoded title; title filter could not be verified for them."} if title_unavailable_forms else {}), + }} if form_title else {}), + "call_chains": call_chains, + "agent_summary": agent_summary, + **({"next_calls": [{ + "method": "form.field.lineage", + "params": { + key: payload.get(key) for key in ("base_id", "ref", "kind", "name", "guid", "form", "form_title", "field", "match_mode") + if payload.get(key) not in {None, ""} + } | {"analysis_profile": "full", "include_calculation": True, "include_call_chain": True}, + "reason": "Fast profile confirmed the data path; request full only when static calculation or call-chain evidence is needed.", + }]} if analysis_profile == "fast" else {}), + **({"calculation": calculation} if calculation else {}), + "limitations": ["Static decoded form and BSL evidence only; no runtime evaluation is performed."] + } + + +def dcs_field_lineage(payload: dict[str, Any]) -> dict[str, Any]: + """Static, object-scoped provenance for one SCD output field.""" + method = "dcs.field.lineage" + field = str(payload.get("field") or "").strip() + if not field: + return invalid_argument(method, "field", "field is required.") + inspected = scd_inspect({**payload, "sections": ["datasets", "fields", "calculated_fields", "resources"], "resolve_metadata": False}) + if inspected.get("status") != "ok": + return {"schema": "onec_dcs_field_lineage.v1", "method": method, "status": inspected.get("status"), "base_id": payload.get("base_id"), "inspect": inspected} + matches: list[dict[str, Any]] = [] + alias_pattern = re.compile(r"(?is)(?P[^,;]+?)\\s+КАК\\s+" + re.escape(field) + r"(?![A-Za-zА-Яа-я0-9_])") + source_pattern = re.compile(r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\.(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)") + bindings_by_dataset: dict[str, dict[str, str]] = {} + + def direct_references(expression: str, bindings: dict[str, str], temporary_bindings: dict[tuple[str, str], tuple[str, str]] | None = None, temporary_expressions: dict[tuple[str, str], str] | None = None, virtual_arguments: dict[str, str] | None = None, where_clause: str | None = None, joins_by_alias: dict[str, list[dict[str, str]]] | None = None) -> list[dict[str, Any]]: + references: list[dict[str, Any]] = [] + for item in source_pattern.finditer(expression): + alias, name = item.group("alias"), item.group("name") + reference: dict[str, Any] = {"alias": alias, "field": name} + source = bindings.get(normalize(alias)) + temporary = (temporary_bindings or {}).get((normalize(alias), normalize(name))) + temporary_expression = (temporary_expressions or {}).get((normalize(alias), normalize(name))) + if temporary_expression and not temporary: + # A temporary-table field can be a static expression rather than + # a plain projection. Follow its direct input fields once; + # retain the table/field in evidence and never evaluate BSL/SQL. + nested_references = direct_references( + temporary_expression, bindings, temporary_bindings, {}, virtual_arguments, where_clause, joins_by_alias, + ) + if nested_references: + for nested in nested_references: + nested["via_temporary_table"] = {"alias": alias, "field": name, "expression": temporary_expression} + nested["resolution"] = "temporary_table_expression" + references.append(nested) + continue + if temporary: + source, name = temporary + reference.update({"field": name, "resolution": "temporary_table_projection"}) + elif normalize(alias).startswith("вт"): + reference["unresolved_reason"] = "temporary_table_projection_not_resolved" + if source: + reference.update({"source": source, "public_path": f"{source}.{name}"}) + if (joins_by_alias or {}).get(normalize(alias)): + reference["joins"] = joins_by_alias[normalize(alias)] + source_parts = source.split(".") + if len(source_parts) >= 3 and normalize(source_parts[-1]) in {"срезпоследних", "остатки", "обороты", "остаткииобороты"}: + reference["virtual_source"] = { + "register": ".".join(source_parts[:-1]), + "virtual_table": source_parts[-1], + "status": "static_source_confirmed", + **({"arguments": virtual_arguments[normalize(source)]} if (virtual_arguments or {}).get(normalize(source)) else {}), + **({"filter": where_clause} if where_clause else {}), + } + references.append(reference) + return references + + for dataset in (inspected.get("sections") or {}).get("datasets") or []: + query = str(dataset.get("query") or "") + dataset_name = str(dataset.get("name") or "") + bindings: dict[str, str] = {} + source_rule = re.compile( + r"(?is)\b(?:ИЗ|СОЕДИНЕНИЕ)\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" + r"(?:\s*\([^()]*\))?(?:\s+КАК\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*))?" + ) + for source_match in source_rule.finditer(query): + source = str(source_match.group("source") or "").strip() + alias = str(source_match.group("alias") or source.rsplit(".", 1)[-1]).strip() + if source and alias: + bindings[normalize(alias)] = source + virtual_arguments: dict[str, str] = {} + for virtual_match in re.finditer( + r"(?is)(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)\s*\((?P[^()]*)\)", query, + ): + virtual_arguments[normalize(str(virtual_match.group("source") or ""))] = str(virtual_match.group("arguments") or "").strip() + where_match = re.search(r"(?is)\bГДЕ\b\s*(.*?)(?=\b(?:СГРУППИРОВАТЬ\s+ПО|УПОРЯДОЧИТЬ\s+ПО|ИТОГИ|ОБЪЕДИНИТЬ)\b|;|\Z)", query) + where_clause = str(where_match.group(1) or "").strip() if where_match else "" + joins_by_alias: dict[str, list[dict[str, str]]] = {} + join_rule = re.compile( + r"(?is)\b(?P(?:ЛЕВОЕ|ПРАВОЕ|ПОЛНОЕ|ВНУТРЕННЕЕ)\s+)?СОЕДИНЕНИЕ\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*(?:\.[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)+)" + r"(?:\s*\([^()]*\))?\s+КАК\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+ПО\s+" + r"(?P.*?)(?=\b(?:ЛЕВОЕ|ПРАВОЕ|ПОЛНОЕ|ВНУТРЕННЕЕ)?\s*СОЕДИНЕНИЕ\b|\b(?:ГДЕ|СГРУППИРОВАТЬ\s+ПО|УПОРЯДОЧИТЬ\s+ПО|ИТОГИ|ОБЪЕДИНИТЬ)\b|;|\Z)" + ) + for join_match in join_rule.finditer(query): + alias = normalize(str(join_match.group("alias") or "")) + if alias: + joins_by_alias.setdefault(alias, []).append({ + "kind": " ".join(str(join_match.group("kind") or "inner").split()).casefold(), + "condition": str(join_match.group("condition") or "").strip(), + }) + bindings_by_dataset[dataset_name] = bindings + temporary_bindings: dict[tuple[str, str], tuple[str, str]] = {} + temporary_rule = re.compile( + r"(?is)\bВЫБРАТЬ\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\." + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+КАК\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)\s+ПОМЕСТИТЬ\s+" + r"(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)" + ) + for temporary_match in temporary_rule.finditer(query): + source = bindings.get(normalize(str(temporary_match.group("source_alias") or ""))) + if source: + temporary_bindings[(normalize(str(temporary_match.group("temporary") or "")), normalize(str(temporary_match.group("output") or "")))] = (source, str(temporary_match.group("source_field") or "")) + # Package queries commonly project many columns before ПОМЕСТИТЬ. + # Capture only direct ``Alias.Field AS Output`` projections, then + # iteratively resolve chains such as втШапка -> втПродукция -> + # ВТ_ПродукцияИтоговый. Expressions and aggregates deliberately stay + # outside this bounded static rule. + temporary_links: dict[tuple[str, str], tuple[str, str]] = {} + temporary_expressions: dict[tuple[str, str], str] = {} + for package_part in re.split(r"(?is);", query): + target_match = re.search(r"(?is)\bПОМЕСТИТЬ\s+(?P[A-Za-zА-Яа-я_][A-Za-zА-Яа-я0-9_]*)", package_part) + select_match = re.search(r"(?is)\bВЫБРАТЬ\s+(?P
ВремяМетод / объектСтатусДлительностьПричина
+
База ещё не загружена.
+

Медленные методы

Требуют внимания

+

Корреляция транспорта

MCP-событие без REST-пары означает, что вызов не дошёл до адаптера.

request_idМетодMCPRESTВремя
+
Снимок ещё не загружен.
+

Детали

diff --git a/plugins/1c/parser/cas_payload.py b/plugins/1c/parser/cas_payload.py index 6a9bb44..73c5fb2 100644 --- a/plugins/1c/parser/cas_payload.py +++ b/plugins/1c/parser/cas_payload.py @@ -25,6 +25,11 @@ def normalized_text_sha1(text: str) -> str: return hashlib.sha1(normalized.encode("utf-8")).hexdigest() +def _text_with_line_ending(text: str, line_ending: str) -> str: + """Normalize caller text first, then render it in a stream's convention.""" + return str(text or "").replace("\r\n", "\n").replace("\r", "\n").replace("\n", line_ending) + + def decode_text(data: bytes) -> tuple[str | None, str | None]: if data.startswith(b"\xef\xbb\xbf"): try: @@ -121,6 +126,188 @@ def stream_blocks_with_data(payload: bytes, *, limit: int = 100) -> list[dict[st return blocks +def structural_stream_blocks_with_data(payload: bytes, *, limit: int = 100) -> list[dict[str, Any]]: + """Read one contiguous stream chain without matching headers inside data. + + Some 1C stream payloads legitimately contain the ASCII sequence used by a + stream header inside a binary member. ``stream_blocks_with_data`` remains + a discovery heuristic for legacy readers; this function follows only the + next header located exactly at the previous member's end and is suitable + for evidence-bearing module decoding. + """ + blocks: list[dict[str, Any]] = [] + first = STREAM_HEADER_RE.search(payload) + if first is None: + return blocks + match = first + while match is not None and len(blocks) < limit: + declared_1 = int(match.group(1), 16) + declared_2 = int(match.group(2), 16) + data_offset = match.end() + data_end = data_offset + declared_2 + if declared_2 <= 0 or data_end > len(payload): + break + data = payload[data_offset:data_end] + text, encoding = decode_text(data) + blocks.append( + { + "header_offset": match.start(), + "header_end": match.end(), + "data_offset": data_offset, + "data_end": data_end, + "declared_1": declared_1, + "declared_2": declared_2, + "bytes": len(data), + "sha1": sha1_hex(data), + "encoding": encoding, + "text": text, + "data": data, + "structural": True, + } + ) + match = STREAM_HEADER_RE.match(payload, data_end) + return blocks + + +def extract_structural_stream_blocks(payload: bytes, *, include_text: bool = False, limit: int = 100) -> list[dict[str, Any]]: + """Public structural stream view with the same shape as discovery blocks.""" + result: list[dict[str, Any]] = [] + for block in structural_stream_blocks_with_data(payload, limit=limit): + text = str(block.get("text") or "") + clean = text.replace("\x00", "") + item = { + **{key: value for key, value in block.items() if key not in {"text", "data"}}, + "text_preview": clean[:500], + "has_bsl_marker": bool(text and any(marker in clean for marker in BSL_MARKERS)), + "has_html_marker": bool(text and any(marker in clean for marker in HTML_MARKERS)), + } + if include_text: + item["text"] = block.get("text") + result.append(item) + return result + + +def decode_declared_utf8_bsl_prefix(payload: bytes, stream_index: int) -> dict[str, Any]: + """Decode a BSL prefix whose byte length is declared by a stream header. + + Object-module containers observed in 1C keep the editable UTF-8 BSL bytes + in the first ``declared_1`` bytes of a fixed-size member. The remaining + member bytes are opaque platform metadata, not source text. This helper + is read-only evidence; it intentionally does not construct replacements. + """ + blocks = structural_stream_blocks_with_data(payload) + if stream_index < 0 or stream_index >= len(blocks): + return {"status": "not_found", "error": "stream_index_not_found"} + block = blocks[stream_index] + data = bytes(block["data"]) + prefix_bytes = int(block["declared_1"]) + if prefix_bytes <= 0 or prefix_bytes > len(data): + return { + "status": "unsupported", + "error": "invalid_declared_bsl_prefix_length", + "declared_1": prefix_bytes, + "member_bytes": len(data), + } + prefix = data[:prefix_bytes] + if not prefix.startswith(b"\xef\xbb\xbf"): + return { + "status": "unsupported", + "error": "declared_bsl_prefix_not_utf8_bom", + "declared_1": prefix_bytes, + "member_bytes": len(data), + } + try: + text = prefix.decode("utf-8-sig", errors="strict") + except UnicodeDecodeError as exc: + return {"status": "unsupported", "error": "declared_bsl_prefix_decode_error", "diagnostics": {"message": str(exc)}} + return { + "status": "ok", + "text": text, + "stream_index": stream_index, + "header_offset": block["header_offset"], + "data_offset": block["data_offset"], + "bsl_prefix_bytes": prefix_bytes, + "opaque_tail_bytes": len(data) - prefix_bytes, + "member_bytes": len(data), + "text_sha1": normalized_text_sha1(text), + "structural": True, + } + + +def replace_declared_utf8_bsl_prefix_same_width( + payload: bytes, + stream_index: int, + *, + text: str, + expected_text_sha1: str | None = None, +) -> tuple[bytes, dict[str, Any]]: + """Replace a proven fixed-width BSL prefix without touching its tail. + + This is deliberately narrower than ``replace_stream_block``. The report + object-module carrier has a fixed-size stream member whose first declared + bytes are UTF-8 source and whose remaining bytes are opaque. A shorter + source is right-padded with spaces *inside the declared source field*; + longer source is rejected. Consequently the member, every following + stream, and the opaque tail stay byte-for-byte identical. + + It does not attempt to synthesize the platform's independent version + atoms. The caller remains responsible for the proven paired + ``__configinfo`` SHA-1 update. + """ + decoded = decode_declared_utf8_bsl_prefix(payload, stream_index) + if decoded.get("status") != "ok": + raise ValueError(str(decoded.get("error") or "declared_bsl_prefix_unavailable")) + old_text = str(decoded["text"]) + if not is_declared_utf8_bsl_source(old_text): + raise ValueError("declared_bsl_prefix_is_not_bsl_source") + old_sha1 = normalized_text_sha1(old_text) + if expected_text_sha1 and expected_text_sha1.lower() != old_sha1: + raise ValueError("expected_text_sha1 does not match declared BSL prefix") + line_ending = "\r\n" if "\r\n" in old_text else "\r" if "\r" in old_text else "\n" + rendered = _text_with_line_ending(text, line_ending) + encoded = b"\xef\xbb\xbf" + rendered.encode("utf-8") + prefix_bytes = int(decoded["bsl_prefix_bytes"]) + if len(encoded) > prefix_bytes: + raise ValueError("replacement_declared_bsl_prefix_exceeds_fixed_width") + # BSL whitespace outside string literals is semantically inert. Padding + # is restricted to the fixed source field and is observable in readback. + padded = encoded + (b" " * (prefix_bytes - len(encoded))) + if len(padded) != prefix_bytes: + raise AssertionError("declared BSL prefix width changed") + data_offset = int(decoded["data_offset"]) + new_payload = payload[:data_offset] + padded + payload[data_offset + prefix_bytes :] + if payload[data_offset + prefix_bytes :] != new_payload[data_offset + prefix_bytes :]: + raise AssertionError("opaque member tail changed") + return new_payload, { + "stream_index": stream_index, + "mode": "declared_utf8_bsl_prefix_same_width", + "old_text_sha1": old_sha1, + "new_text_sha1": normalized_text_sha1(rendered), + "old_bsl_prefix_bytes": prefix_bytes, + "new_bsl_source_bytes": len(encoded), + "padding_bytes": prefix_bytes - len(encoded), + "opaque_tail_bytes": int(decoded["opaque_tail_bytes"]), + "opaque_tail_preserved": True, + "old_text_preview": old_text[:500], + "new_text_preview": rendered[:500], + } + + +def is_declared_utf8_bsl_source(text: str) -> bool: + """Recognize source evidence in a declared UTF-8 stream prefix. + + A module may legitimately consist solely of comments, while other stream + members can also have a UTF-8 prefix (for example a brace descriptor). + The prefix is BSL evidence only when it has a normal BSL marker or every + nonblank source line is a BSL line comment. + """ + source = str(text or "").lstrip("\ufeff") + if any(marker in source for marker in BSL_MARKERS): + return True + lines = [line.strip() for line in source.replace("\r\n", "\n").replace("\r", "\n").split("\n") if line.strip()] + return bool(lines) and all(line.startswith("//") for line in lines) + + def stream_header(size: int) -> bytes: if size < 0 or size > 0xFFFFFFFF: raise ValueError("stream size is outside 8-hex header range") @@ -159,9 +346,20 @@ def replace_stream_block( if not old: raise ValueError("replace.old is required") count = int(replace.get("count") or 1) - if old not in old_text: + # Public code.read normalizes BSL to LF while streams often retain + # CRLF. Treat that representation difference as irrelevant, but do + # not loosen matching of any other character (spaces/tabs remain + # exact). The replacement is rendered back in the stream's original + # line-ending convention to avoid unrelated formatting churn. + line_ending = "\r\n" if "\r\n" in old_text else "\r" if "\r" in old_text else "\n" + source_old = old + source_new = _text_with_line_ending(new, line_ending) if ("\n" in new or "\r" in new) else new + if source_old not in old_text: + source_old = _text_with_line_ending(old, line_ending) + source_new = _text_with_line_ending(new, line_ending) + if source_old not in old_text: raise ValueError("replace.old was not found in stream text") - text = old_text.replace(old, new, count) + text = old_text.replace(source_old, source_new, count) routine_edit = None if routine is not None: if old_text is None: @@ -261,7 +459,29 @@ def classify_payload(data: bytes, *, include_text: bool = False, include_tree: b decoded = decode_payload_lossless(data) payload = decoded.get("payload") if isinstance(decoded.get("payload"), (bytes, bytearray)) else b"" markers = payload_markers(bytes(payload)) - stream_blocks = extract_stream_blocks(bytes(payload), include_text=include_text) + # Prefer proven contiguous boundaries for normal container decoding. Keep + # the regex scan only as a discovery fallback for legacy irregular blobs. + stream_blocks = extract_structural_stream_blocks(bytes(payload), include_text=include_text) + if not stream_blocks and "stream_headers" in markers: + stream_blocks = extract_stream_blocks(bytes(payload), include_text=include_text) + # A report object module observed in ConfigCAS stores source in the + # declared UTF-8 prefix of a fixed-size stream member. The remainder is + # opaque platform state and must never be exposed as BSL. Keep this as + # read-only evidence: replacement still requires a separately proven + # reverse codec for that carrier. + if stream_blocks: + for stream_index, stream in enumerate(stream_blocks): + declared_prefix = decode_declared_utf8_bsl_prefix(bytes(payload), stream_index) + if declared_prefix.get("status") != "ok" or not is_declared_utf8_bsl_source(str(declared_prefix.get("text") or "")): + continue + stream["declared_utf8_bsl_prefix"] = { + key: declared_prefix[key] + for key in ("bsl_prefix_bytes", "opaque_tail_bytes", "member_bytes", "text_sha1", "structural") + if key in declared_prefix + } + if include_text: + stream["text"] = declared_prefix["text"] + stream["text_preview"] = str(declared_prefix["text"] or "").replace("\x00", "")[:500] text = decoded.get("text") tree = None root = None diff --git a/plugins/1c/parser/form_payload.py b/plugins/1c/parser/form_payload.py index ff2f487..7c221bd 100644 --- a/plugins/1c/parser/form_payload.py +++ b/plugins/1c/parser/form_payload.py @@ -2202,7 +2202,16 @@ def section_record_semantic_properties(row: dict[str, Any], parameters: list[dic mapped: set[int] = set() for index, (group, name) in SECTION_RECORD_SEMANTIC_PROPERTIES.items(): mapped.add(index) - add_grouped_property(groups, group, semantic_property(name, parameter_value(parameters, index), index=index)) + value = parameter_value(parameters, index) + source = "form_payload" + # A managed-form record can store the localized title outside its + # direct parameter #3. The row decoder already resolves that exact + # title path, so expose it instead of misleading an agent with an + # empty semantic «Заголовок» beside a non-empty public row.title. + if index == 3 and value in {None, ""} and row.get("title") not in {None, ""}: + value = row.get("title") + source = "form_payload_title_path" + add_grouped_property(groups, group, semantic_property(name, value, index=index, source=source)) if row.get("category"): add_grouped_property(groups, "Основные", semantic_property("Категория", row.get("category"), source="decoder")) add_grouped_property(groups, "Основные", semantic_property("Вид", row.get("category"), source="decoder")) @@ -3850,6 +3859,37 @@ def enrich_button_command_semantics(items: list[dict[str, Any]], links: list[dic ) +FORM_AUXILIARY_ITEM_TYPES = { + "Контекстное меню", "Расширенная подсказка", "SearchStringAddition", + "ViewStatusAddition", "SearchControlAddition", +} + + +def form_item_coverage_summary(items: list[dict[str, Any]]) -> dict[str, Any]: + """Report semantic coverage without auxiliary form records hiding control quality.""" + buckets = { + "all_items": {"items": 0, "mapped": 0, "unmapped": 0}, + "interactive_items": {"items": 0, "mapped": 0, "unmapped": 0}, + "auxiliary_items": {"items": 0, "mapped": 0, "unmapped": 0}, + } + for item in items: + if not isinstance(item, dict): + continue + coverage = (item.get("semantic") or {}).get("coverage") if isinstance(item.get("semantic"), dict) else None + if not isinstance(coverage, dict): + continue + target = "auxiliary_items" if str(item.get("type_name") or "") in FORM_AUXILIARY_ITEM_TYPES else "interactive_items" + for bucket_name in ("all_items", target): + bucket = buckets[bucket_name] + bucket["items"] += 1 + bucket["mapped"] += int(coverage.get("mapped") or 0) + bucket["unmapped"] += int(coverage.get("unmapped") or 0) + for bucket in buckets.values(): + bucket["total"] = bucket["mapped"] + bucket["unmapped"] + bucket["status"] = "partial" if bucket["unmapped"] else "ok" + return buckets + + def decode_form_payload( tree: Any, *, @@ -3906,11 +3946,13 @@ def decode_form_payload( form_parameters = form_common_parameters(tree, limit=max_parameters) form_semantic = form_common_semantic(form_parameters, include_diagnostics=include_parameters) enrich_form_common_semantic(form_semantic, items) + coverage_summary = form_item_coverage_summary(items) result = { "schema": "onec_form_payload_profile.v1", "status": "ok" if root.get("root_marker") == "4" else "not_form_payload", "root": root, "form_semantic": form_semantic, + "item_coverage": coverage_summary, **({"form_parameters": form_parameters} if include_parameters else {}), "events": events, "items": items, diff --git a/plugins/1c/parser/scd_payload.py b/plugins/1c/parser/scd_payload.py new file mode 100644 index 0000000..140846c --- /dev/null +++ b/plugins/1c/parser/scd_payload.py @@ -0,0 +1,463 @@ +"""Lossless read-only decoder for 1C Data Composition Schema SQL payloads. + +The payload stored in ConfigCAS is commonly a compressed stream with a small +binary prefix followed by an XML ``SchemaFile`` document. This module does +not infer SCD semantics from names: every returned item is backed by an XML +node in that document. +""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET +import xml.parsers.expat as expat +import html +import hashlib +import re +from typing import Any + +from .payload import decode_payload_lossless + + +QUERY_PARAMETER_RE = re.compile(r"&([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)") +QUERY_SOURCE_RE = re.compile(r"(?:\bИЗ|\bFROM|\bJOIN|\bСОЕДИНЕНИЕ)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*(?:\.[A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)+)", re.IGNORECASE) +QUERY_SOURCE_BINDING_RE = re.compile(r"(?:\bИЗ|\bFROM|\bJOIN|\bСОЕДИНЕНИЕ)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*(?:\.[A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)+)(?:\s+(?:КАК|AS)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*))?", re.IGNORECASE) +QUERY_SELECT_RE = re.compile(r"\b(?:ВЫБРАТЬ|SELECT)\b(.*?)(?=\b(?:ИЗ|FROM)\b)", re.IGNORECASE | re.DOTALL) +QUERY_ALIAS_RE = re.compile(r"\b(?:КАК|AS)\s+([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)(?=\s*(?:,|\r?\n|$))", re.IGNORECASE) + + +def local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] if "}" in tag else tag + + +def direct_child(node: ET.Element, name: str) -> ET.Element | None: + return next((child for child in node if local_name(child.tag) == name), None) + + +def child_text(node: ET.Element, *names: str) -> str: + for name in names: + child = direct_child(node, name) + if child is not None: + value = "".join(child.itertext()).strip() + if value: + return value + return "" + + +def query_without_line_comments(text: str) -> str: + """Remove 1C query ``//`` comments without touching quoted string literals.""" + + result: list[str] = [] + index = 0 + quoted = False + while index < len(text): + char = text[index] + if char == '"': + result.append(char) + if quoted and index + 1 < len(text) and text[index + 1] == '"': + result.append('"') + index += 2 + continue + quoted = not quoted + index += 1 + continue + if not quoted and char == "/" and index + 1 < len(text) and text[index + 1] == "/": + line_end = text.find("\n", index) + if line_end < 0: + break + result.append("\n") + index = line_end + 1 + continue + result.append(char) + index += 1 + return "".join(result) + + +def node_path(root: ET.Element, target: ET.Element) -> str: + """Produce a stable, human-readable evidence path without XML prefixes.""" + + def visit(node: ET.Element, prefix: str) -> str | None: + name = local_name(node.tag) + current = f"{prefix}/{name}" if prefix else f"/{name}" + if node is target: + return current + positions: dict[str, int] = {} + for child in node: + child_name = local_name(child.tag) + positions[child_name] = positions.get(child_name, 0) + 1 + child_prefix = f"{current}[{positions[child_name]}]" + found = visit(child, child_prefix) + if found: + return found + return None + + return visit(root, "") or "/" + + +def xml_from_scd_payload(data: bytes) -> tuple[ET.Element | None, dict[str, Any]]: + decoded = decode_payload_lossless(data) + payload = decoded.get("payload") + if not isinstance(payload, (bytes, bytearray)): + return None, {"status": "undecodable", "code": "SCD_PAYLOAD_EMPTY"} + raw = bytes(payload) + start = raw.find(b"" + end = raw.find(end_marker, start) + xml_bytes = raw[start : end + len(end_marker)] if end >= 0 else raw[start:] + try: + root = ET.fromstring(xml_bytes.decode("utf-8-sig")) + except (UnicodeDecodeError, ET.ParseError) as exc: + return None, { + "status": "undecodable", + "code": "SCD_XML_INVALID", + "message": str(exc), + "compression": decoded.get("compression"), + "raw_bytes": decoded.get("raw_bytes"), + "payload_bytes": decoded.get("payload_bytes"), + } + return root, { + "status": "ok", + "compression": decoded.get("compression"), + "raw_bytes": decoded.get("raw_bytes"), + "payload_bytes": decoded.get("payload_bytes"), + "xml_offset": start, + "xml_bytes": len(xml_bytes), + "xml_root": local_name(root.tag), + } + + +def scd_node_item(root: ET.Element, node: ET.Element, category: str) -> dict[str, Any]: + """Return only direct, documented XML values for one SCD item.""" + + item_name = child_text(node, "name", "dataPath", "field") + if not item_name and not list(node): + item_name = (node.text or "").strip() + item: dict[str, Any] = { + "name": item_name, + "source": {"kind": "scd_xml", "path": node_path(root, node)}, + } + expression = child_text(node, "expression") + if expression: + item["expression"] = expression + query = child_text(node, "query") + if query: + item["query"] = query + value_type_node = direct_child(node, "valueType") + if value_type_node is None: + value_type_node = direct_child(node, "type") + value_type = "" + if value_type_node is not None: + value_type = child_text(value_type_node, "type") or (value_type_node.text or "").strip() + if value_type: + item["value_type"] = value_type + if category == "datasets": + item["type"] = node.attrib.get("{http://www.w3.org/2001/XMLSchema-instance}type") or node.attrib.get("type") or "" + return item + + +def inspect_scd_payload(data: bytes, *, sections: list[str] | None = None) -> dict[str, Any]: + """Decode a DataCompositionSchema XML stream from SQL storage. + + Unknown or absent XML nodes become empty lists. They are deliberately not + synthesized from report code or form attributes. + """ + + requested = sections or ["parameters", "datasets", "fields", "calculated_fields", "resources", "settings", "variants", "total_fields"] + root, container = xml_from_scd_payload(data) + if root is None: + return {"status": "partial", "container": container, "sections": {name: [] for name in requested}} + schema = next((node for node in root.iter() if local_name(node.tag) == "dataCompositionSchema"), None) + if schema is None: + return { + "status": "partial", + "container": {**container, "code": "SCD_SCHEMA_NODE_NOT_FOUND"}, + "sections": {name: [] for name in requested}, + } + node_names = { + "parameters": {"parameter"}, + "datasets": {"dataSet"}, + "fields": {"field"}, + "calculated_fields": {"calculatedField"}, + "resources": {"resource"}, + "settings": {"settings", "Settings"}, + "variants": {"settingsVariant", "variant"}, + "total_fields": {"totalField"}, + } + result: dict[str, list[dict[str, Any]]] = {} + skipped_unnamed: dict[str, int] = {} + for section in requested: + names = node_names.get(section) + if not names: + result[section] = [] + continue + raw_items = [scd_node_item(schema, node, section) for node in schema.iter() if local_name(node.tag) in names] + result[section] = [item for item in raw_items if item.get("name")] + if len(raw_items) != len(result[section]): + skipped_unnamed[section] = len(raw_items) - len(result[section]) + declared = [str(item.get("name")) for item in result.get("parameters") or [] if item.get("name")] + declared_by_normalized = {name.casefold(): name for name in declared} + query_references: list[dict[str, Any]] = [] + referenced_normalized: set[str] = set() + for dataset in result.get("datasets") or []: + references: list[str] = [] + for found in QUERY_PARAMETER_RE.finditer(query_without_line_comments(str(dataset.get("query") or ""))): + name = found.group(1) + if name.casefold() not in {value.casefold() for value in references}: + references.append(name) + referenced_normalized.add(name.casefold()) + if references: + query_references.append({"dataset": dataset.get("name"), "parameters": references}) + analysis = { + "kind": "raw_query_parameter_token_scan", + "declared_parameters": declared, + "query_parameter_references": query_references, + "referenced_not_declared_in_schema": sorted( + {name for item in query_references for name in item["parameters"] if name.casefold() not in declared_by_normalized}, + key=str.casefold, + ), + "declared_not_referenced_in_dataset_queries": [name for name in declared if name.casefold() not in referenced_normalized], + } + settings_tags = { + "groupings": {"groupItems", "grouping"}, + "filters": {"selection", "filter"}, + "orders": {"order", "sorting"}, + "conditional_appearance": {"appearance", "conditionalAppearance"}, + } + settings_context: dict[str, Any] = {"status": "not_present", "sections": {}} + for context_name, tags in settings_tags.items(): + nodes = [node for node in schema.iter() if local_name(node.tag) in tags] + if not nodes: + continue + records: list[dict[str, Any]] = [] + for node in nodes: + tokens = [] + for child in node.iter(): + if local_name(child.tag) not in {"field", "dataPath", "left", "right", "group"} or list(child): + continue + value = (child.text or "").strip() + if value and value.casefold() not in {item.casefold() for item in tokens}: + tokens.append(value) + if tokens: + records.append({"path": node_path(schema, node), "tokens": tokens}) + if records: + settings_context["status"] = "found" + settings_context["sections"][context_name] = records + analysis["settings_context"] = settings_context + query_sources: list[dict[str, Any]] = [] + query_output_aliases: list[dict[str, Any]] = [] + for dataset in result.get("datasets") or []: + query = query_without_line_comments(str(dataset.get("query") or "")) + sources = list(dict.fromkeys(match.group(1) for match in QUERY_SOURCE_RE.finditer(query))) + if sources: + bindings = [] + for match in QUERY_SOURCE_BINDING_RE.finditer(query): + source, alias = match.group(1), match.group(2) + item = {"source": source} + if alias: + item["alias"] = alias + if item not in bindings: + bindings.append(item) + query_sources.append({"dataset": dataset.get("name"), "sources": sources, "bindings": bindings}) + select_match = QUERY_SELECT_RE.search(query) + if select_match: + aliases = list(dict.fromkeys(match.group(1) for match in QUERY_ALIAS_RE.finditer(select_match.group(1)))) + if aliases: + query_output_aliases.append({"dataset": dataset.get("name"), "aliases": aliases}) + if query_sources: + analysis["data_source_references"] = {"kind": "raw_query_source_token_scan", "datasets": query_sources} + direct_field_references: list[dict[str, Any]] = [] + for dataset in query_sources: + query = query_without_line_comments(str(next((item.get("query") for item in result.get("datasets") or [] if item.get("name") == dataset.get("dataset")), ""))) + references: list[dict[str, str]] = [] + for binding in dataset.get("bindings") or []: + alias = str(binding.get("alias") or "") + if not alias: + continue + matcher = re.compile(r"\b" + re.escape(alias) + r"\.([A-Za-z_\u0400-\u04ff][A-Za-z0-9_\u0400-\u04ff]*)(?![A-Za-z0-9_\u0400-\u04ff.])", re.IGNORECASE) + for match in matcher.finditer(query): + item = {"alias": alias, "field": match.group(1)} + if item not in references: + references.append(item) + if references: + direct_field_references.append({"dataset": dataset.get("dataset"), "references": references}) + if direct_field_references: + analysis["query_direct_field_references"] = {"kind": "direct_alias_field_token_scan", "datasets": direct_field_references} + field_names = {str(item.get("name")).casefold(): str(item.get("name")) for item in result.get("fields") or [] if item.get("name")} + calculated_field_names = {str(item.get("name")).casefold(): str(item.get("name")) for item in result.get("calculated_fields") or [] if item.get("name")} + declared_field_names = {**field_names, **calculated_field_names} + total_names = [str(item.get("name")) for item in result.get("total_fields") or [] if item.get("name")] + if total_names: + analysis["total_field_references"] = { + "fields": total_names, + "missing_from_declared_fields": [name for name in total_names if name.casefold() not in declared_field_names], + "status": "checked" if "fields" in result and "calculated_fields" in result else "field_sections_not_requested", + } + if query_output_aliases: + analysis["query_output_aliases"] = { + "kind": "select_clause_alias_scan", + "datasets": query_output_aliases, + "not_declared_as_scd_fields": sorted( + { + alias + for dataset in query_output_aliases + for alias in dataset["aliases"] + if alias.casefold() not in declared_field_names + }, + key=str.casefold, + ), + } + return { + "status": "ok", + "container": container, + "sections": result, + "analysis": analysis, + "diagnostics": {"skipped_unnamed_xml_nodes": skipped_unnamed} if skipped_unnamed else {}, + } + + +def plan_scd_scalar_patch( + data: bytes, + *, + section: str, + name: str, + property_name: str, + value: str, +) -> dict[str, Any]: + """Build a byte-preserving patch for one direct scalar SCD XML property. + + Only query/expression properties are accepted in this first writer layer. + The XML element span is collected by Expat from the original byte stream; + all bytes outside the scalar content stay unchanged, including the 1C + binary prefix/trailer. No database operation is performed here. + """ + + allowed = { + "datasets": ({"dataSet"}, {"query"}), + "calculated_fields": ({"calculatedField"}, {"expression"}), + "resources": ({"resource"}, {"expression"}), + } + tags_and_properties = allowed.get(section) + if not tags_and_properties or property_name not in tags_and_properties[1]: + return { + "status": "invalid_argument", + "code": "SCD_PATCH_PROPERTY_UNSUPPORTED", + "message": "Only datasets.query, calculated_fields.expression, and resources.expression are writable.", + } + root, container = xml_from_scd_payload(data) + if root is None: + return {"status": "undecodable", "container": container} + decoded = decode_payload_lossless(data) + payload = bytes(decoded["payload"]) + xml_start = payload.find(b"" + xml_end = payload.find(xml_end_marker, xml_start) + if xml_start < 0 or xml_end < 0: + return {"status": "undecodable", "container": container} + xml_end += len(xml_end_marker) + xml = payload[xml_start:xml_end] + target_tags = tags_and_properties[0] + stack: list[dict[str, Any]] = [] + records: list[dict[str, Any]] = [] + + def start_element(tag: str, _attrs: dict[str, str]) -> None: + local = local_name(tag) + position = parser.CurrentByteIndex + end = xml.find(b">", position) + frame: dict[str, Any] = {"tag": local, "depth": len(stack) + 1, "content_start": end + 1} + if local in target_tags: + frame["record"] = {"tag": local, "depth": len(stack) + 1, "properties": {}} + if stack: + parent_record = next((item.get("record") for item in reversed(stack) if item.get("record")), None) + if parent_record and len(stack) + 1 == parent_record["depth"] + 1 and local in {"name", "dataPath", property_name}: + frame["property_record"] = parent_record + stack.append(frame) + + def end_element(_tag: str) -> None: + frame = stack.pop() + end = parser.CurrentByteIndex + property_record = frame.get("property_record") + if property_record is not None: + raw_text = xml[int(frame["content_start"]):end] + if b"<" not in raw_text: + property_record["properties"][frame["tag"]] = { + "start": int(frame["content_start"]), + "end": end, + "text": html.unescape(raw_text.decode("utf-8")), + } + record = frame.get("record") + if record is not None: + identity = record["properties"].get("name") or record["properties"].get("dataPath") + record["name"] = identity.get("text") if identity else "" + records.append(record) + + parser = expat.ParserCreate() + parser.StartElementHandler = start_element + parser.EndElementHandler = end_element + try: + parser.Parse(xml, True) + except expat.ExpatError as exc: + return {"status": "undecodable", "container": container, "code": "SCD_XML_INVALID", "message": str(exc)} + matches = [record for record in records if str(record.get("name") or "") == name] + if not matches: + return {"status": "not_found", "code": "SCD_PATCH_TARGET_NOT_FOUND", "container": container} + if len(matches) > 1: + return {"status": "ambiguous", "code": "SCD_PATCH_TARGET_AMBIGUOUS", "container": container, "matches": len(matches)} + property_record = (matches[0].get("properties") or {}).get(property_name) + if not property_record: + return {"status": "not_found", "code": "SCD_PATCH_PROPERTY_NOT_FOUND", "container": container} + old = str(property_record["text"]) + if old == value: + return {"status": "unchanged", "container": container, "old": old, "new": value} + escaped = html.escape(value, quote=False).encode("utf-8") + patched_xml = xml[: property_record["start"]] + escaped + xml[property_record["end"] :] + patched_payload = payload[:xml_start] + patched_xml + payload[xml_end:] + from .payload import encode_payload_lossless + patched_data = encode_payload_lossless(decoded, payload=patched_payload) + return { + "status": "planned", + "container": container, + "old": old, + "new": value, + "payload": patched_data, + "expected_sha1": hashlib.sha1(data).hexdigest(), + "result_sha1": hashlib.sha1(patched_data).hexdigest(), + "changed_bytes": len(patched_data) - len(data), + } + + +def compare_scd_semantics(active: dict[str, Any], saved: dict[str, Any]) -> dict[str, Any]: + """Compare decoded SCD sections by semantic content, never by storage id.""" + + section_names = sorted(set((active.get("sections") or {}).keys()) | set((saved.get("sections") or {}).keys())) + sections: dict[str, dict[str, Any]] = {} + counts = {"added": 0, "removed": 0, "changed": 0, "unchanged": 0} + for section in section_names: + def index(items: Any) -> dict[str, Any]: + result: dict[str, Any] = {} + for ordinal, item in enumerate(items or []): + if not isinstance(item, dict): + continue + key = str(item.get("name") or f"#{ordinal}") + result[key] = {key: value for key, value in item.items() if key != "source"} + return result + active_items, saved_items = index((active.get("sections") or {}).get(section)), index((saved.get("sections") or {}).get(section)) + added = sorted(set(saved_items) - set(active_items), key=str.casefold) + removed = sorted(set(active_items) - set(saved_items), key=str.casefold) + changed = sorted([name for name in set(active_items) & set(saved_items) if active_items[name] != saved_items[name]], key=str.casefold) + unchanged = len(set(active_items) & set(saved_items)) - len(changed) + sections[section] = {"added": added, "removed": removed, "changed": changed, "unchanged": unchanged} + counts["added"] += len(added); counts["removed"] += len(removed); counts["changed"] += len(changed); counts["unchanged"] += unchanged + return {"status": "unchanged" if not any(counts[key] for key in ("added", "removed", "changed")) else "changed", "sections": sections, "counts": counts} diff --git a/plugins/1c/prompts/system.md b/plugins/1c/prompts/system.md index 6c3881e..05a2ed6 100644 --- a/plugins/1c/prompts/system.md +++ b/plugins/1c/prompts/system.md @@ -1,9 +1,11 @@ -Ты 1C-агент для анализа и разработки в живой конфигурации 1C через адаптер. +Ты 1C-агент для анализа и разработки в живой конфигурации 1C через MCP-адаптер. Отвечай по-русски, кратко и доказательно. Не выдавай гипотезу за факт. ## Работа с адаптером - Для любого запроса к живой базе сначала явно зафиксируй `base_id`. Адаптер не использует базу по умолчанию. +- Вызывай адаптер только инструментом MCP `onec_request`. REST SQL-адаптер, его `/rpc`, SQL-таблицы и технические маршруты не являются инструментами агента. +- Для чтения текущей конфигурации передавай `source_state=working`; не называй результат активированным runtime-состоянием без явного сравнения. - Слово «пользователь» без уточнения означает пользователя информационной базы, видимого в Конфигураторе. Начинай с `infobase.users.search`/`infobase.user.get`: `dbo.v8users` является источником платформенной идентичности, признаков аутентификации, `RolesID` и системного администратора. - Пользователь БСП — отдельная прикладная сущность из справочника `Пользователи`. Используй `access.users.search`/`access.user.explain` только при явном запросе про БСП, группы доступа, профили или RLS. Всегда называй такой результат «пользователь БСП». - Не подменяй роли пользователя Конфигуратора профилями или группами БСП. `RolesID` подтверждает назначенный платформенный набор, но точные имена его ролей должны быть получены через штатный runtime API `ПользователиИнформационнойБазы`; если runtime-канала нет, отвечай `runtime_required`, а не угадывай по БСП. @@ -12,12 +14,13 @@ - Для безопасной проверки результата используй `infobase.user.password.status`: он возвращает только `empty`, `set` или `standard_authentication_disabled`, не раскрывая хеши и `Data`. - Новый пароль для `set` является одноразовым секретным вводом: не повторяй его в ответе, журнале, артефакте или диагностике. Отсутствие сервисной аутентификации допускается только при явно включённом адаптером тестовом режиме. - При сопоставлении по имени показывай два независимых слоя: `infobase_user` и `bsp_catalog_user`. Совпадение имени является корреляцией, а не доказательством тождественности или одинакового набора ролей. -- Если в контексте уже есть точный `module_ref`, `module_id`, GUID, storage key или read selector, используй прямое чтение (`modules.read` или соответствующий read-метод) перед глобальным поиском. +- Начинай с полного публичного имени: область расширения + `ref` объекта + имя дочернего объекта. GUID, storage key, имя SQL-файла и `module_ref` не являются входом обычного агента. +- Если в контексте есть `read_selector.selector_token`, вызывай только указанный в нём read-метод с этим токеном; не раскрывай и не восстанавливай его внутренний маршрут. - Не начинай с широкого `modules.search`, если есть точная ссылка на модуль или объект. - `metadata.definition.find` и глобальный поиск используй для навигации, а не как единственное доказательство отсутствия кода. - `not_found` означает только "не найдено выбранным методом в выбранной области". Для расширений, ConfigCAS и неполных индексов это не доказывает, что объекта или строки нет. - `partial`, `truncated=true`, лимит сканирования или timeout делают результат недоказательным. В ответе явно помечай такой результат как неполный и меняй стратегию на более точечную. -- Не увеличивай глобальный `scan_limit` как первый способ решения. Сначала сузь область: объект, расширение, GUID, `module_ref`, конкретный метод, шаблон или макет. +- Не увеличивай глобальный `scan_limit` как первый способ решения. Сначала сузь область: объект, расширение, полный `ref`, имя формы/команды, конкретный метод, шаблон или макет. Не проси и не подставляй GUID либо `module_ref`. ## Доказательная логика diff --git a/scripts/analyze_1c_adapter_audit.py b/scripts/analyze_1c_adapter_audit.py new file mode 100644 index 0000000..50fb9e9 --- /dev/null +++ b/scripts/analyze_1c_adapter_audit.py @@ -0,0 +1,67 @@ +"""Summarize privacy-safe adapter JSONL telemetry. + +Run inside the REST container or copy /data/adapter-audit.jsonl from it. +No BSL text, SQL payload, or credentials are expected in the source log. +""" +from __future__ import annotations + +import argparse +import json +from collections import Counter, defaultdict +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--log", default="/data/adapter-audit.jsonl") + parser.add_argument("--slow-ms", type=int, default=5_000) + parser.add_argument("--limit", type=int, default=20) + args = parser.parse_args() + path = Path(args.log) + rows: list[dict] = [] + malformed_rows = 0 + if not path.exists(): + print(json.dumps({"schema": "onec_adapter_audit_summary.v1", "status": "log_not_found", "log": str(path)}, ensure_ascii=False, indent=2)) + return 0 + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + try: + item = json.loads(line) + except json.JSONDecodeError: + malformed_rows += 1 + continue + if item.get("event") == "adapter_rpc": + rows.append(item) + by_base = Counter(str((row.get("request") or {}).get("base_id") or "") for row in rows) + exceptions = [row for row in rows if str(row.get("status") or "") == "exception" or row.get("error") == "request_exception"] + rejected = [row for row in rows if str(row.get("status") or "") in {"blocked", "unsupported", "invalid_argument"}] + slow = sorted((row for row in rows if int(row.get("duration_ms") or 0) >= args.slow_ms), key=lambda row: int(row.get("duration_ms") or 0), reverse=True) + methods = Counter(str(row.get("method") or "") for row in exceptions) + print(json.dumps({ + "schema": "onec_adapter_audit_summary.v1", + "events": len(rows), + "malformed_rows": malformed_rows, + "time_range": {"from": rows[0].get("time") if rows else None, "to": rows[-1].get("time") if rows else None}, + "bases": dict(by_base), + "adapter_exceptions": len(exceptions), + "expected_rejections": len(rejected), + "exception_methods": dict(methods.most_common(args.limit)), + "slow_threshold_ms": args.slow_ms, + "slow": [ + {"time": row.get("time"), "base_id": (row.get("request") or {}).get("base_id"), "method": row.get("method"), "status": row.get("status"), "error": row.get("error"), "duration_ms": row.get("duration_ms"), "request_id": row.get("request_id")} + for row in slow[:args.limit] + ], + "recent_exceptions": [ + {"time": row.get("time"), "base_id": (row.get("request") or {}).get("base_id"), "method": row.get("method"), "error": row.get("error"), "exception_type": row.get("exception_type"), "duration_ms": row.get("duration_ms"), "request_id": row.get("request_id")} + for row in exceptions[-args.limit:] + ], + "findings": [ + *([{"priority": "P1", "kind": "adapter_exception", "count": len(exceptions), "next_action": "Inspect the matching REST request_id and exception_type; reproduce only on upo_test before changing code."}] if exceptions else []), + *([{"priority": "P2", "kind": "slow_calls", "count": len(slow), "next_action": "Inspect timings_ms for the listed methods; optimise only after a repeated pattern is confirmed."}] if slow else []), + *([{"priority": "P2", "kind": "malformed_audit_rows", "count": malformed_rows, "next_action": "Inspect log rotation and container shutdown events."}] if malformed_rows else []), + ], + }, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/analyze_1c_moxel_merge_row_bands.py b/scripts/analyze_1c_moxel_merge_row_bands.py index fd57a27..0c273af 100644 --- a/scripts/analyze_1c_moxel_merge_row_bands.py +++ b/scripts/analyze_1c_moxel_merge_row_bands.py @@ -204,7 +204,7 @@ def render_markdown(payload: dict[str, Any]) -> str: def main() -> int: parser = argparse.ArgumentParser(description="Analyze SQL MOXCEL merge-block row/size scalar bands against XML merge rows.") - parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011") + parser.add_argument("--adapter-url", default="http://docker.cin.su:8011") parser.add_argument("--base-id", default="upo_test") parser.add_argument("--owner-kind", default="Document") parser.add_argument("--owner-name", default="АвансовыйОтчет") diff --git a/scripts/analyze_1c_moxel_merge_slot_candidates.py b/scripts/analyze_1c_moxel_merge_slot_candidates.py index db80b67..2623abb 100644 --- a/scripts/analyze_1c_moxel_merge_slot_candidates.py +++ b/scripts/analyze_1c_moxel_merge_slot_candidates.py @@ -323,7 +323,7 @@ def render_markdown(payload: dict[str, Any]) -> str: def main() -> int: parser = argparse.ArgumentParser(description="Score SQL MOXCEL merge-block numeric slots against XML merge range fields.") - parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011") + parser.add_argument("--adapter-url", default="http://docker.cin.su:8011") parser.add_argument("--base-id", default="upo_test") parser.add_argument("--owner-kind", default="Document") parser.add_argument("--owner-name", default="АвансовыйОтчет") diff --git a/scripts/audit_1c_adapter_coverage.py b/scripts/audit_1c_adapter_coverage.py index 7b3109b..1107772 100644 --- a/scripts/audit_1c_adapter_coverage.py +++ b/scripts/audit_1c_adapter_coverage.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import Any, Callable -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" DEFAULT_BASE_ID = "upo_test" # Metadata kinds that either own application data or expose values through the diff --git a/scripts/capture_1c_template_probe.py b/scripts/capture_1c_template_probe.py index 46f555e..d547513 100644 --- a/scripts/capture_1c_template_probe.py +++ b/scripts/capture_1c_template_probe.py @@ -279,7 +279,7 @@ def render_markdown(snapshot: dict[str, Any], diff: dict[str, Any] | None, previ def main() -> int: parser = argparse.ArgumentParser(description="Capture and diff a live 1C MOXCEL template probe snapshot.") - parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011") + parser.add_argument("--adapter-url", default="http://docker.cin.su:8011") parser.add_argument("--base-id", default="upo_test") parser.add_argument("--file-name", help="Explicit ConfigCAS file name. If omitted, use the newest MOXCEL payload.") parser.add_argument("--scan-limit", type=int, default=30) diff --git a/scripts/check_1c_adapter_verification_stack.py b/scripts/check_1c_adapter_verification_stack.py index 300603a..fa73450 100644 --- a/scripts/check_1c_adapter_verification_stack.py +++ b/scripts/check_1c_adapter_verification_stack.py @@ -10,7 +10,7 @@ from typing import Any ROOT = Path(__file__).resolve().parents[1] -DEFAULT_REST_ADAPTER_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_REST_ADAPTER_URL = "http://docker.cin.su:8011" DEFAULT_MCP_URL = "http://docker.cin.su:8021" SAVED_STATE_TABLES = ("ConfigSave", "ConfigCASSave") diff --git a/scripts/check_1c_mcp_adapter_contract.py b/scripts/check_1c_mcp_adapter_contract.py index 8f05e10..eb93c0f 100644 --- a/scripts/check_1c_mcp_adapter_contract.py +++ b/scripts/check_1c_mcp_adapter_contract.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse import json +import os import sys from pathlib import Path from typing import Any @@ -509,11 +510,13 @@ def check_contract() -> dict[str, Any]: calls: list[tuple[str, str, Any]] = [] - def fake_http_json(method: str, path: str, body: Any | None = None, *, timeout: float | None = None) -> dict[str, Any]: + def fake_http_json(method: str, path: str, body: Any | None = None, *, timeout: float | None = None, request_id: str | None = None) -> dict[str, Any]: calls.append((method, path, body)) return {"status": "ok", "method": body.get("method") if isinstance(body, dict) else "health"} original_http_json = adapter_mcp.http_json + previous_diagnostic_mode = os.environ.get("ONEC_MCP_ALLOW_DIAGNOSTIC") + os.environ["ONEC_MCP_ALLOW_DIAGNOSTIC"] = "true" adapter_mcp.http_json = fake_http_json try: calls.clear() @@ -562,6 +565,10 @@ def check_contract() -> dict[str, Any]: issues.append({"code": "mcp_rpc_body_method_mismatch", "method": method, "body": body}) finally: adapter_mcp.http_json = original_http_json + if previous_diagnostic_mode is None: + os.environ.pop("ONEC_MCP_ALLOW_DIAGNOSTIC", None) + else: + os.environ["ONEC_MCP_ALLOW_DIAGNOSTIC"] = previous_diagnostic_mode return { "schema": "onec_mcp_adapter_contract_check.v1", diff --git a/scripts/check_1c_metadata_kind_fixtures.py b/scripts/check_1c_metadata_kind_fixtures.py index 4cb3323..33feaa4 100644 --- a/scripts/check_1c_metadata_kind_fixtures.py +++ b/scripts/check_1c_metadata_kind_fixtures.py @@ -389,7 +389,7 @@ def check_manifest( manifest_path: Path, *, live: bool = False, - adapter_url: str = "http://docker-gpu.cin.su:8011", + adapter_url: str = "http://docker.cin.su:8011", service_token: str = "", timeout: float = 90, base_overrides: dict[str, str] | None = None, @@ -453,7 +453,7 @@ 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("--adapter-url", default="http://docker.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") diff --git a/scripts/check_1c_saved_state_strict_readiness.py b/scripts/check_1c_saved_state_strict_readiness.py index ddc814d..3a4585a 100644 --- a/scripts/check_1c_saved_state_strict_readiness.py +++ b/scripts/check_1c_saved_state_strict_readiness.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" DEFAULT_TABLES = ("ConfigCASSave", "ConfigSave") ALLOWED_TABLES = {"ConfigCASSave", "ConfigSave"} diff --git a/scripts/check_1c_verify_reports.py b/scripts/check_1c_verify_reports.py index f828cd6..194e167 100644 --- a/scripts/check_1c_verify_reports.py +++ b/scripts/check_1c_verify_reports.py @@ -968,7 +968,10 @@ def validate_saved_state_module( if report.get("status") == "verified_and_rolled_back": if write_plan.get("allowed") is not True: failures.append({"code": "saved_state_module_write_plan_not_allowed", "path": str(path), "write_plan": write_plan}) - if write_plan.get("apply_method") != "metadata.module.write_apply": + if write_plan.get("apply_method") not in { + "metadata.module.write_apply", + "form_embedded_module_handler_write_apply", + }: failures.append({"code": "saved_state_module_apply_method_unexpected", "path": str(path), "write_plan": write_plan}) if write_plan.get("target_kind") != "module": failures.append({"code": "saved_state_module_target_kind_unexpected", "path": str(path), "write_plan": write_plan}) diff --git a/scripts/deploy_1c_adapter_stack.ps1 b/scripts/deploy_1c_adapter_stack.ps1 index 365d2da..e6de580 100644 --- a/scripts/deploy_1c_adapter_stack.ps1 +++ b/scripts/deploy_1c_adapter_stack.ps1 @@ -1,12 +1,16 @@ param( - [string]$RestDockerHost = "ssh://docker-gpu.cin.su", + [string]$RestDockerHost = "ssh://docker.cin.su", [string]$McpDockerHost = "ssh://docker.cin.su", - [string]$RestComposePath = "core/deploy/docker-gpu/adapter-1c/compose.yaml", + [string]$RestComposePath = "core/deploy/docker/adapter-1c/compose.yaml", [string]$McpComposePath = "core/deploy/docker/adapter-1c-mcp/compose.yaml", + [string]$RestEnvFile, + [string]$McpEnvFile, [string]$RestServiceName = "adapter-1c-rest", + [string]$RestAuditServiceName = "adapter-1c-audit", [string]$McpServiceName = "adapter-1c-mcp", + [string]$McpAuditServiceName = "adapter-1c-mcp-audit", [string[]]$BaseId, - [string]$AdapterUrl = "http://docker-gpu.cin.su:8011", + [string]$AdapterUrl = "http://docker.cin.su:8011", [string]$McpUrl = "http://docker.cin.su:8021", [string]$ObjectRef, [string]$ObjectKind, @@ -19,6 +23,7 @@ param( [switch]$SkipRest, [switch]$SkipMcp, [switch]$SkipVerify, + [switch]$SkipDrainCheck, [switch]$SkipWritePlanSafetySmoke, [switch]$SkipWriteRollbackSafetySmoke, [switch]$SkipSavedStateDiffSmoke, @@ -50,9 +55,13 @@ function Invoke-ComposeUp { [string]$Label, [string]$DockerHost, [string]$ComposePath, + [string]$EnvFile, [string]$ServiceName ) $command = @("docker", "--host", $DockerHost, "compose", "-f", $ComposePath, "up", "-d", "--no-deps") + if ($EnvFile) { + $command = @("docker", "--host", $DockerHost, "compose", "--env-file", $EnvFile, "-f", $ComposePath, "up", "-d", "--no-deps") + } if (-not $NoBuild) { $command += "--build" } @@ -60,6 +69,34 @@ function Invoke-ComposeUp { Invoke-CheckedCommand -Label $Label -Command $command } +function Wait-RestAdapterIdle { + if ($SkipDrainCheck) { + Write-Host "[skip] REST drain check was explicitly skipped" + return + } + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSec) + $lastIssue = "" + while ([DateTime]::UtcNow -lt $deadline) { + try { + $health = Invoke-RestMethod -Method Get -Uri ($AdapterUrl.TrimEnd('/') + "/health") -TimeoutSec 10 + $runtime = $health.runtime + if (-not $runtime) { + Write-Warning "REST adapter is a legacy image without runtime drain telemetry; proceeding with this one transition deployment" + return + } + if ($runtime -and $runtime.state -eq "ready" -and [int]$runtime.active_rpc_count -eq 0) { + Write-Host "[ready] REST adapter has no active RPC calls" + return + } + $lastIssue = "state=$($runtime.state) active_rpc_count=$($runtime.active_rpc_count)" + } catch { + $lastIssue = $_.Exception.Message + } + Start-Sleep -Seconds 2 + } + throw "REST adapter did not become idle before deployment: $lastIssue. Re-run later or pass -SkipDrainCheck only after confirming no write is active." +} + function Ensure-RestServiceToken { if ($env:ONEC_ADAPTER_SERVICE_TOKEN) { return @@ -135,15 +172,27 @@ try { $env:ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN = "true" } Ensure-RestServiceToken + Wait-RestAdapterIdle Invoke-ComposeUp ` -Label "Deploy REST adapter" ` -DockerHost $RestDockerHost ` -ComposePath $RestComposePath ` + -EnvFile $RestEnvFile ` -ServiceName $RestServiceName Write-ContainerSummary ` -Label "REST adapter container" ` -DockerHost $RestDockerHost ` -ServiceName $RestServiceName + Invoke-ComposeUp ` + -Label "Deploy REST audit analyzer" ` + -DockerHost $RestDockerHost ` + -ComposePath $RestComposePath ` + -EnvFile $RestEnvFile ` + -ServiceName $RestAuditServiceName + Write-ContainerSummary ` + -Label "REST audit analyzer container" ` + -DockerHost $RestDockerHost ` + -ServiceName $RestAuditServiceName } if (-not $SkipMcp) { @@ -151,11 +200,22 @@ try { -Label "Deploy MCP proxy" ` -DockerHost $McpDockerHost ` -ComposePath $McpComposePath ` + -EnvFile $McpEnvFile ` -ServiceName $McpServiceName Write-ContainerSummary ` -Label "MCP proxy container" ` -DockerHost $McpDockerHost ` -ServiceName $McpServiceName + Invoke-ComposeUp ` + -Label "Deploy MCP audit analyzer" ` + -DockerHost $McpDockerHost ` + -ComposePath $McpComposePath ` + -EnvFile $McpEnvFile ` + -ServiceName $McpAuditServiceName + Write-ContainerSummary ` + -Label "MCP audit analyzer container" ` + -DockerHost $McpDockerHost ` + -ServiceName $McpAuditServiceName } if (-not $SkipVerify) { diff --git a/scripts/deploy_embeddings.ps1 b/scripts/deploy_embeddings.ps1 new file mode 100644 index 0000000..27e108f --- /dev/null +++ b/scripts/deploy_embeddings.ps1 @@ -0,0 +1,89 @@ +param( + [string]$DockerHost = "ssh://docker-gpu.cin.su", + [string]$ComposeFile = "core/deploy/docker-gpu/embeddings/compose.yaml", + [string]$EnvFile = "core/deploy/docker-gpu/embeddings/.env.example", + [string]$BaseUrl = "http://docker-gpu.cin.su:8082", + [string]$ExpectedModel = "qwen3-embedding-0.6b", + [int]$WaitSeconds = 900, + [switch]$ConfigOnly, + [switch]$Pull, + [switch]$Down +) + +$ErrorActionPreference = "Stop" + +if (-not (Test-Path -LiteralPath $ComposeFile)) { + throw "Compose file not found: $ComposeFile" +} + +if (-not (Test-Path -LiteralPath $EnvFile)) { + throw "Env file not found: $EnvFile" +} + +$composeArgs = @( + "--host", $DockerHost, + "compose", + "--env-file", $EnvFile, + "-f", $ComposeFile +) + +if ($ConfigOnly) { + docker @composeArgs config + exit $LASTEXITCODE +} + +if ($Down) { + docker @composeArgs down + exit $LASTEXITCODE +} + +if ($Pull) { + docker @composeArgs pull + if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE + } +} + +docker @composeArgs up -d +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +$deadline = [DateTimeOffset]::UtcNow.AddSeconds($WaitSeconds) +$lastError = "" +do { + try { + $health = Invoke-RestMethod -Method Get -Uri "$($BaseUrl.TrimEnd('/'))/health" -TimeoutSec 10 + if ($health.status -eq "ok") { + $models = Invoke-RestMethod -Method Get -Uri "$($BaseUrl.TrimEnd('/'))/v1/models" -TimeoutSec 10 + $modelIds = @($models.data | ForEach-Object { $_.id }) + if ($modelIds -notcontains $ExpectedModel) { + throw "Expected model '$ExpectedModel' is absent. Loaded: $($modelIds -join ', ')" + } + + $body = @{ + model = $ExpectedModel + input = @("поиск процедуры проведения документа 1С") + } | ConvertTo-Json -Depth 4 + $embedding = Invoke-RestMethod ` + -Method Post ` + -Uri "$($BaseUrl.TrimEnd('/'))/v1/embeddings" ` + -ContentType "application/json; charset=utf-8" ` + -Body ([Text.Encoding]::UTF8.GetBytes($body)) ` + -TimeoutSec 120 + $dimensions = @($embedding.data[0].embedding).Count + if ($dimensions -le 0) { + throw "Embedding endpoint returned an empty vector." + } + Write-Host "Embedding endpoint is ready: model=$ExpectedModel dimensions=$dimensions url=$BaseUrl" + exit 0 + } + } + catch { + $lastError = $_.Exception.Message + } + Start-Sleep -Seconds 5 +} while ([DateTimeOffset]::UtcNow -lt $deadline) + +docker @composeArgs logs --tail 100 +throw "Embedding endpoint did not become ready in $WaitSeconds seconds. Last error: $lastError" diff --git a/scripts/embed_1c_code_vectors.py b/scripts/embed_1c_code_vectors.py new file mode 100644 index 0000000..5bb9eb5 --- /dev/null +++ b/scripts/embed_1c_code_vectors.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import argparse +import json +from typing import Any + +from embed_1c_semantic_cache import DEFAULT_ADAPTER_URL, adapter_call, batched, embedding_model_label +from rag_embedding_providers import LOCAL_HASHING_MODEL, LOCAL_HASHING_PROVIDER, embed_texts, provider_metadata + + +def code_embedding_model_label(*, provider: str, model: str, dimensions: int) -> str: + label = embedding_model_label(provider=provider, model=model) + normalized_provider = str(provider or "").strip().lower().replace("_", "-") + if normalized_provider in {"openai-compatible", "openai"} and int(dimensions or 0) > 0: + return f"{label}@d{int(dimensions)}" + return label + + +def embed_pending_code_vectors( + *, + adapter_url: str, + base_id: str, + limit: int = 100, + batch_size: int = 16, + embedding_provider: str = LOCAL_HASHING_PROVIDER, + embedding_model: str = LOCAL_HASHING_MODEL, + dimensions: int = 384, + embedding_base_url: str = "", + embedding_api_key_env: str = "OPENAI_API_KEY", + chunk_kinds: tuple[str, ...] | list[str] = ("routine",), + max_text_chars: int = 4000, + dry_run: bool = False, + timeout_seconds: int = 180, +) -> dict[str, Any]: + stored_model = code_embedding_model_label( + provider=embedding_provider, + model=embedding_model, + dimensions=dimensions, + ) + pending = adapter_call( + adapter_url, + "metadata.code_vector.pending", + { + "base_id": base_id, + "embedding_model": stored_model, + "limit": int(limit or 100), + "chunk_kinds": list(dict.fromkeys(str(value).strip().lower() for value in chunk_kinds if str(value).strip())), + "max_text_chars": int(max_text_chars), + }, + timeout_seconds=timeout_seconds, + ) + if pending.get("status") != "ok": + return { + "schema": "onec_code_vector_embedding_worker.v1", + "status": pending.get("status") or "error", + "error": pending.get("error"), + "pending": pending, + } + chunks = [item for item in pending.get("chunks") or [] if isinstance(item, dict)] + upserts: list[dict[str, Any]] = [] + skipped: list[dict[str, Any]] = [] + for batch in batched(chunks, max(int(batch_size or 1), 1)): + texts = [str(item.get("text") or "") for item in batch] + vectors = embed_texts( + texts, + provider=embedding_provider, + model=embedding_model, + dimensions=dimensions, + base_url=embedding_base_url, + api_key_env=embedding_api_key_env, + timeout_seconds=timeout_seconds, + ) + for item, vector in zip(batch, vectors): + chunk_id = str(item.get("chunk_id") or "") + text_sha1 = str(item.get("text_sha1") or "") + if not chunk_id or not text_sha1 or not vector: + skipped.append( + { + "chunk_id": chunk_id or None, + "reason": "missing_chunk_id_text_sha1_or_embedding", + } + ) + continue + if dry_run: + upserts.append( + { + "status": "dry_run", + "chunk_id": chunk_id, + "text_sha1": text_sha1, + "dimensions": len(vector), + } + ) + continue + result = adapter_call( + adapter_url, + "metadata.code_vector.embedding.upsert", + { + "base_id": base_id, + "chunk_id": chunk_id, + "text_sha1": text_sha1, + "embedding_model": stored_model, + "embedding": vector, + }, + timeout_seconds=timeout_seconds, + ) + upserts.append( + { + "status": result.get("status"), + "error": result.get("error"), + "chunk_id": chunk_id, + "text_sha1": text_sha1, + "dimensions": result.get("dimensions") or len(vector), + } + ) + observed_dimensions = next( + ( + int(item.get("dimensions") or 0) + for item in upserts + if int(item.get("dimensions") or 0) > 0 + ), + int(dimensions), + ) + provider = provider_metadata( + provider=embedding_provider, + model=embedding_model, + dimensions=observed_dimensions, + base_url=embedding_base_url, + ) + return { + "schema": "onec_code_vector_embedding_worker.v1", + "status": "ok", + "base_id": base_id, + "adapter_url": adapter_url, + "dry_run": bool(dry_run), + "embedding": { + "provider": provider.get("embedding_provider"), + "model": embedding_model, + "stored_embedding_model": stored_model, + "dimensions": observed_dimensions, + "chunk_kinds": list(chunk_kinds), + "max_text_chars": int(max_text_chars), + }, + "counts": { + "pending": len(chunks), + "processed": len(upserts), + "stored": len([item for item in upserts if item.get("status") == "ok"]), + "conflicts": len([item for item in upserts if item.get("status") == "conflict"]), + "skipped": len(skipped), + "errors": len( + [ + item + for item in upserts + if item.get("status") not in {"ok", "dry_run", "conflict"} + ] + ), + }, + "upserts": upserts, + **({"skipped": skipped} if skipped else {}), + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Embed pending BSL code chunks from the 1C adapter local code index." + ) + parser.add_argument("--adapter-url", default=DEFAULT_ADAPTER_URL) + parser.add_argument("--base-id", required=True) + parser.add_argument("--limit", type=int, default=100) + parser.add_argument("--batch-size", type=int, default=16) + parser.add_argument( + "--embedding-provider", + default=LOCAL_HASHING_PROVIDER, + choices=[LOCAL_HASHING_PROVIDER, "openai-compatible"], + ) + parser.add_argument("--embedding-model", default=LOCAL_HASHING_MODEL) + parser.add_argument("--dimensions", type=int, default=384) + parser.add_argument("--embedding-base-url", default="") + parser.add_argument("--embedding-api-key-env", default="OPENAI_API_KEY") + parser.add_argument( + "--chunk-kind", + action="append", + choices=["routine", "module"], + default=None, + help="Chunk kind to embed; repeat to include both. Defaults to routine.", + ) + parser.add_argument( + "--max-text-chars", + type=int, + default=4000, + help="Skip oversized chunks in this pass. Defaults to 4000 characters.", + ) + parser.add_argument("--timeout-seconds", type=int, default=180) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + result = embed_pending_code_vectors( + adapter_url=args.adapter_url, + base_id=args.base_id, + limit=args.limit, + batch_size=args.batch_size, + embedding_provider=args.embedding_provider, + embedding_model=args.embedding_model, + dimensions=args.dimensions, + embedding_base_url=args.embedding_base_url, + embedding_api_key_env=args.embedding_api_key_env, + chunk_kinds=tuple(args.chunk_kind or ["routine"]), + max_text_chars=args.max_text_chars, + dry_run=args.dry_run, + timeout_seconds=args.timeout_seconds, + ) + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + counts = result.get("counts") or {} + print( + "code vector embeddings: " + f"pending={counts.get('pending')} processed={counts.get('processed')} " + f"stored={counts.get('stored')} conflicts={counts.get('conflicts')} " + f"errors={counts.get('errors')}" + ) + return ( + 0 + if result.get("status") == "ok" + and int((result.get("counts") or {}).get("errors") or 0) == 0 + else 1 + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/embed_1c_semantic_cache.py b/scripts/embed_1c_semantic_cache.py index ef14b28..7231458 100644 --- a/scripts/embed_1c_semantic_cache.py +++ b/scripts/embed_1c_semantic_cache.py @@ -9,7 +9,7 @@ from typing import Any from rag_embedding_providers import LOCAL_HASHING_MODEL, LOCAL_HASHING_PROVIDER, embed_texts, provider_metadata -DEFAULT_ADAPTER_URL = "http://docker-gpu.cin.su:8011/rpc" +DEFAULT_ADAPTER_URL = "http://docker.cin.su:8011/rpc" def adapter_call(adapter_url: str, method: str, payload: dict[str, Any], *, timeout_seconds: int = 180) -> dict[str, Any]: diff --git a/scripts/execute_1c_saved_state_copy_sql.ps1 b/scripts/execute_1c_saved_state_copy_sql.ps1 index d36e7b5..5efc1b4 100644 --- a/scripts/execute_1c_saved_state_copy_sql.ps1 +++ b/scripts/execute_1c_saved_state_copy_sql.ps1 @@ -6,7 +6,7 @@ param( [string]$SqlPath = "reports/1c-sql/upo_test/prepare-saved-state-copy.sql", [string]$SqlPlanReport = "reports/1c-sql/upo_test/prepare-saved-state-copy-sql.json", [string]$PlanPath = "reports/1c-sql/upo_test/saved-state-copy-plan.json", - [string]$BaseUrl = "http://docker-gpu.cin.su:8011", + [string]$BaseUrl = "http://docker.cin.su:8011", [string]$ExpectedBaseId, [ValidateSet("ConfigSave", "ConfigCASSave")] [string]$ExpectedTargetTable = "ConfigSave", diff --git a/scripts/export_1c_access_role_audit.py b/scripts/export_1c_access_role_audit.py index 9beeb31..8dc5b04 100644 --- a/scripts/export_1c_access_role_audit.py +++ b/scripts/export_1c_access_role_audit.py @@ -13,7 +13,7 @@ from typing import Any ROOT = Path(__file__).resolve().parents[1] -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" DEFAULT_REPORT_ROOT = ROOT / "reports" / "1c-access" diff --git a/scripts/inventory_1c_configuration_templates.py b/scripts/inventory_1c_configuration_templates.py index 0afa691..00f627e 100644 --- a/scripts/inventory_1c_configuration_templates.py +++ b/scripts/inventory_1c_configuration_templates.py @@ -115,7 +115,7 @@ def render_markdown(payload: dict[str, Any]) -> str: def main() -> int: parser = argparse.ArgumentParser(description="Inventory templates across live 1C configuration objects.") - parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011") + parser.add_argument("--adapter-url", default="http://docker.cin.su:8011") parser.add_argument("--base-id", default="upo_test") parser.add_argument("--kind", action="append", dest="kinds", help="Repeatable metadata kind filter.") parser.add_argument("--page-size", type=int, default=200) diff --git a/scripts/inventory_1c_template_payloads.py b/scripts/inventory_1c_template_payloads.py index 3ddf585..3361a19 100644 --- a/scripts/inventory_1c_template_payloads.py +++ b/scripts/inventory_1c_template_payloads.py @@ -134,7 +134,7 @@ def render_markdown(payload: dict[str, Any]) -> str: def main() -> int: parser = argparse.ArgumentParser(description="Inventory recent 1C template payload candidates from ConfigCAS.") - parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011") + parser.add_argument("--adapter-url", default="http://docker.cin.su:8011") parser.add_argument("--base-id", default="upo_test") parser.add_argument("--scan-limit", type=int, default=200) parser.add_argument("--max-cells", type=int, default=200) diff --git a/scripts/plan_1c_saved_state_copy.py b/scripts/plan_1c_saved_state_copy.py index 9649550..3ddcea0 100644 --- a/scripts/plan_1c_saved_state_copy.py +++ b/scripts/plan_1c_saved_state_copy.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" DISCOVERY_KINDS = ("Catalog", "Document", "DataProcessor", "Report") SOURCE_TABLES = {"Config", "ConfigCAS"} TARGET_TABLES = {"ConfigSave", "ConfigCASSave"} diff --git a/scripts/profile_1c_extension_templates.py b/scripts/profile_1c_extension_templates.py index cf8eee3..b88d0a4 100644 --- a/scripts/profile_1c_extension_templates.py +++ b/scripts/profile_1c_extension_templates.py @@ -160,7 +160,7 @@ def render_markdown(payload: dict[str, Any]) -> str: def main() -> int: parser = argparse.ArgumentParser(description="Profile extension common template payloads from live SQL ConfigCAS.") - parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011") + parser.add_argument("--adapter-url", default="http://docker.cin.su:8011") parser.add_argument("--base-id", default="upo_test") parser.add_argument("--extension", required=True) parser.add_argument("--query", default="t_MOXEL") diff --git a/scripts/profile_1c_forms.py b/scripts/profile_1c_forms.py index 07862b2..222644e 100644 --- a/scripts/profile_1c_forms.py +++ b/scripts/profile_1c_forms.py @@ -268,7 +268,7 @@ def render_markdown(profile: dict[str, Any]) -> str: def main() -> int: parser = argparse.ArgumentParser(description="Profile decoded 1C managed forms and highlight decoder gaps.") parser.add_argument("--input-json", type=Path, help="Existing metadata.object.form.details JSON.") - parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011") + parser.add_argument("--adapter-url", default="http://docker.cin.su:8011") parser.add_argument("--base-id", default="upo_test") parser.add_argument("--kind") parser.add_argument("--name") diff --git a/scripts/profile_1c_tabular_templates.py b/scripts/profile_1c_tabular_templates.py index b4af61e..dac9b11 100644 --- a/scripts/profile_1c_tabular_templates.py +++ b/scripts/profile_1c_tabular_templates.py @@ -314,7 +314,7 @@ def main() -> int: "--inventory-json", default=str(Path("Z:/codex/LLM/reports/1c-template-baselines/upo_test_configuration_tabular_templates.json")), ) - parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011") + parser.add_argument("--adapter-url", default="http://docker.cin.su:8011") parser.add_argument("--base-id", default="upo_test") parser.add_argument("--workers", type=int, default=8) parser.add_argument("--timeout-seconds", type=int, default=120) diff --git a/scripts/rag_embedding_providers.py b/scripts/rag_embedding_providers.py index e34f192..d369b85 100644 --- a/scripts/rag_embedding_providers.py +++ b/scripts/rag_embedding_providers.py @@ -41,12 +41,16 @@ def embed_texts_openai_compatible( *, model: str, base_url: str, + dimensions: int = 0, api_key: str = "", timeout_seconds: int = 120, ) -> list[list[float]]: if not model: raise ValueError("embedding_model is required") - body = json.dumps({"model": model, "input": texts}, ensure_ascii=False).encode("utf-8") + request_payload: dict[str, Any] = {"model": model, "input": texts} + if int(dimensions or 0) > 0: + request_payload["dimensions"] = int(dimensions) + body = json.dumps(request_payload, ensure_ascii=False).encode("utf-8") headers = {"Content-Type": "application/json"} if api_key: headers["Authorization"] = f"Bearer {api_key}" @@ -67,6 +71,13 @@ def embed_texts_openai_compatible( if not isinstance(embedding, list) or not embedding: raise ValueError("Embedding response item has no embedding[]") vector = [float(value) for value in embedding] + if int(dimensions or 0) > 0: + if len(vector) < int(dimensions): + raise ValueError( + f"Embedding response returned {len(vector)} dimensions, " + f"fewer than requested {int(dimensions)}" + ) + vector = vector[: int(dimensions)] by_index[int(index)] = l2_normalize(vector) vectors = [by_index[index] for index in range(len(texts)) if index in by_index] if len(vectors) != len(texts): @@ -96,6 +107,7 @@ def embed_texts( texts, model=model, base_url=base_url, + dimensions=dimensions, api_key=api_key, timeout_seconds=timeout_seconds, ) diff --git a/scripts/report_1c_agent_working_view.py b/scripts/report_1c_agent_working_view.py index 1bb9724..62108e4 100644 --- a/scripts/report_1c_agent_working_view.py +++ b/scripts/report_1c_agent_working_view.py @@ -9,7 +9,7 @@ import urllib.request from typing import Any -DEFAULT_ADAPTER_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_ADAPTER_URL = "http://docker.cin.su:8011" for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): diff --git a/scripts/run_1c_form_command_binding_learning.py b/scripts/run_1c_form_command_binding_learning.py index 8b85bbd..132e273 100644 --- a/scripts/run_1c_form_command_binding_learning.py +++ b/scripts/run_1c_form_command_binding_learning.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Any -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" DEFAULT_BASE_ID = "upo_test" DEFAULT_TABLE = "ConfigCASSave" DEFAULT_FILE_NAME = "f96a0c45-3eff-11f1-8287-005056b0d483__25c39fbf-35a4-4b43-8e3f-cd1f91082c88.0" diff --git a/scripts/run_1c_repository_runner.ps1 b/scripts/run_1c_repository_runner.ps1 index 13c2900..581148c 100644 --- a/scripts/run_1c_repository_runner.ps1 +++ b/scripts/run_1c_repository_runner.ps1 @@ -84,7 +84,8 @@ while ($listener.IsListening) { $context = $listener.GetContext() try { if ($context.Request.HttpMethod -eq 'GET' -and $context.Request.Url.AbsolutePath -eq '/healthz') { Send-Json $context 200 @{status='ok';service='onec-repository-runner'}; continue } - if ($context.Request.HttpMethod -ne 'POST' -or $context.Request.Url.AbsolutePath -ne '/repository/execute') { Send-Json $context 404 @{status='not_found'}; continue } + $requestPath = $context.Request.Url.AbsolutePath + if ($context.Request.HttpMethod -ne 'POST' -or $requestPath -notin @('/repository/execute', '/configuration/activation/debug')) { Send-Json $context 404 @{status='not_found'}; continue } $expected = [Environment]::GetEnvironmentVariable('ONEC_REPOSITORY_RUNNER_TOKEN', 'Process') if (-not $expected) { $expected = [Environment]::GetEnvironmentVariable('ONEC_REPOSITORY_RUNNER_TOKEN', 'Machine') } if (-not $expected -or $context.Request.Headers['Authorization'] -ne "Bearer $expected") { Send-Json $context 401 @{status='unauthorized'}; continue } @@ -93,6 +94,71 @@ while ($listener.IsListening) { $all = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json $base = $all.([string]$payload.base_id) if (-not $base -or -not $base.repository) { Send-Json $context 400 @{status='not_configured'}; continue } + if ($requestPath -eq '/configuration/activation/debug') { + $layer = if ($payload.layer) { [string]$payload.layer } else { 'all' } + $mode = if ($payload.mode) { ([string]$payload.mode).ToLowerInvariant() } else { 'debug' } + $requestId = [string]$payload.request_id + $fingerprint = ([string]$payload.fingerprint).ToLowerInvariant() + if ($layer -notin @('all', 'base_saved_state', 'extension_saved_state') -or $mode -ne 'debug') { + Send-Json $context 400 @{status='invalid_request';message='a supported layer and mode=debug are required'} + continue + } + if ([bool]$requestId -ne [bool]$fingerprint -or ($requestId -and ($requestId -notmatch '^actreq-[0-9a-fA-F]{32}$' -or $fingerprint -notmatch '^[0-9a-f]{64}$'))) { + Send-Json $context 400 @{status='invalid_request';message='request_id and a 64-hex fingerprint must be supplied together'} + continue + } + $config = $base.repository + $selectorCount = 0 + foreach ($selectorKey in @('file', 'server', 'name')) { + if ([string]$config.infobase.$selectorKey) { $selectorCount++ } + } + $designerConfigured = [bool]([string]$config.designer_path) + $designerAvailable = $designerConfigured -and (Test-Path -LiteralPath ([string]$config.designer_path) -PathType Leaf) + $selectorConfigured = $selectorCount -eq 1 + $ready = $designerAvailable -and $selectorConfigured + $debugAcceptance = $null + if ($requestId) { + $receiptText = "base_id=$([string]$payload.base_id)`nlayer=$layer`nrequest_id=$requestId`nfingerprint=$fingerprint`nmode=debug" + $receiptBytes = [Text.Encoding]::UTF8.GetBytes($receiptText) + $receiptHash = [Security.Cryptography.SHA256]::Create() + try { + $receipt = if ($ready) { -join ($receiptHash.ComputeHash($receiptBytes) | ForEach-Object { $_.ToString('x2') }) } else { $null } + } finally { $receiptHash.Dispose() } + $debugAcceptance = @{ + accepted=$ready + request_id=$requestId + fingerprint=$fingerprint + receipt=$receipt + } + } + $result = @{ + schema='onec_configuration_activation_runner_probe.v1' + status=$(if ($ready) {'ready'} else {'not_ready'}) + base_id=[string]$payload.base_id + layer=$layer + runner=@{ + kind='local' + reachable=$true + designer_path_configured=$designerConfigured + designer_available=$designerAvailable + infobase_selector_configured=$selectorConfigured + } + operation=@{ + kind=$(if ($layer -eq 'base_saved_state') {'/UpdateDBCfg'} else {$null}) + execution_supported=$false + extension_manual_only=$layer -in @('all', 'extension_saved_state') + } + debug_acceptance=$debugAcceptance + execution=@{ + mode='debug' + performed=$false + designer_started=$false + active_configuration_changed=$false + } + } + Send-Json $context 200 $result + continue + } $result = Invoke-RepositoryAction $base.repository $payload Send-Json $context $(if ($result.status -eq 'ok') {200} else {409}) $result } catch { Send-Json $context 500 @{status='runner_error';message=$_.Exception.Message} } diff --git a/scripts/run_1c_repository_runner.py b/scripts/run_1c_repository_runner.py index 59d3579..671cc22 100644 --- a/scripts/run_1c_repository_runner.py +++ b/scripts/run_1c_repository_runner.py @@ -49,7 +49,7 @@ class Handler(BaseHTTPRequestHandler): self._json(404, {"status": "not_found"}) def do_POST(self) -> None: # noqa: N802 - if self.path != "/repository/execute": + if self.path not in {"/repository/execute", "/configuration/activation/debug"}: self._json(404, {"status": "not_found"}) return if not authorized(self.headers.get("Authorization", "")): @@ -66,6 +66,61 @@ class Handler(BaseHTTPRequestHandler): self._json(400, {"status": "invalid_request", "message": str(exc)}) return base_id = str(payload.get("base_id") or "").strip() + if self.path == "/configuration/activation/debug": + layer = str(payload.get("layer") or "all").strip() + mode = str(payload.get("mode") or "debug").strip().casefold() + request_id = str(payload.get("request_id") or "").strip() + fingerprint = str(payload.get("fingerprint") or "").strip().casefold() + if not base_id or layer not in {"all", "base_saved_state", "extension_saved_state"} or mode != "debug": + self._json( + 400, + { + "status": "invalid_request", + "message": "base_id, a supported layer, and mode=debug are required", + }, + ) + return + if bool(request_id) != bool(fingerprint) or ( + request_id + and ( + not request_id.startswith("actreq-") + or len(request_id) != len("actreq-") + 32 + or any(char not in "0123456789abcdef" for char in request_id[len("actreq-"):].casefold()) + or len(fingerprint) != 64 + or any(char not in "0123456789abcdef" for char in fingerprint) + ) + ): + self._json( + 400, + { + "status": "invalid_request", + "message": "request_id and a 64-hex fingerprint must be supplied together", + }, + ) + return + config, error = repository_control.repository_config(base_id) + if error: + self._json(400, error) + return + if (config.get("runner") or {}).get("kind") != "local": + self._json( + 400, + { + "status": "invalid_config", + "message": "Windows runner base configuration must use runner.kind=local", + }, + ) + return + result = repository_control.activation_debug_probe( + base_id, + config, + layer=layer, + timeout_seconds=10, + request_id=request_id, + fingerprint=fingerprint, + ) + self._json(200 if result.get("status") in {"ready", "not_ready"} else 409, result) + return action = str(payload.get("action") or "").strip().casefold() objects = payload.get("objects") or [] if not base_id or action not in {"report", "lock", "unlock", "commit"}: diff --git a/scripts/search_1c_code_vectors.py b/scripts/search_1c_code_vectors.py new file mode 100644 index 0000000..ffb04e6 --- /dev/null +++ b/scripts/search_1c_code_vectors.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import argparse +import json +from typing import Any + +from embed_1c_code_vectors import code_embedding_model_label, embed_pending_code_vectors +from embed_1c_semantic_cache import DEFAULT_ADAPTER_URL, adapter_call +from rag_embedding_providers import LOCAL_HASHING_MODEL, LOCAL_HASHING_PROVIDER, embed_texts, provider_metadata + + +DEFAULT_QWEN3_CODE_RETRIEVAL_INSTRUCTION = ( + "Given a natural-language software task, retrieve the relevant 1C Enterprise " + "BSL source-code fragment that implements or explains it" +) + + +def prepare_query_embedding_text( + query: str, + *, + embedding_model: str, + query_instruction: str | None, +) -> tuple[str, str]: + instruction = query_instruction + if instruction is None and "qwen3-embedding" in str(embedding_model or "").strip().lower(): + instruction = DEFAULT_QWEN3_CODE_RETRIEVAL_INSTRUCTION + clean_instruction = str(instruction or "").strip() + if not clean_instruction: + return query, "" + return f"Instruct: {clean_instruction}\nQuery:{query}", clean_instruction + + +def search_code_vectors( + *, + adapter_url: str, + base_id: str, + query: str, + limit: int = 10, + scan_limit: int = 2000, + embedding_provider: str = LOCAL_HASHING_PROVIDER, + embedding_model: str = LOCAL_HASHING_MODEL, + dimensions: int = 384, + embedding_base_url: str = "", + embedding_api_key_env: str = "OPENAI_API_KEY", + query_instruction: str | None = None, + embed_pending: bool = False, + embed_limit: int = 100, + embed_batch_size: int = 16, + timeout_seconds: int = 180, +) -> dict[str, Any]: + refresh = None + if embed_pending: + refresh = embed_pending_code_vectors( + adapter_url=adapter_url, + base_id=base_id, + limit=embed_limit, + batch_size=embed_batch_size, + embedding_provider=embedding_provider, + embedding_model=embedding_model, + dimensions=dimensions, + embedding_base_url=embedding_base_url, + embedding_api_key_env=embedding_api_key_env, + timeout_seconds=timeout_seconds, + ) + query_embedding_text, applied_instruction = prepare_query_embedding_text( + query, + embedding_model=embedding_model, + query_instruction=query_instruction, + ) + query_embedding = embed_texts( + [query_embedding_text], + provider=embedding_provider, + model=embedding_model, + dimensions=dimensions, + base_url=embedding_base_url, + api_key_env=embedding_api_key_env, + timeout_seconds=timeout_seconds, + )[0] + stored_model = code_embedding_model_label( + provider=embedding_provider, + model=embedding_model, + dimensions=len(query_embedding), + ) + result = adapter_call( + adapter_url, + "metadata.code_vector.search", + { + "base_id": base_id, + "query": query, + "query_embedding": query_embedding, + "embedding_model": stored_model, + "limit": limit, + "scan_limit": scan_limit, + "verify": True, + "strict": True, + }, + timeout_seconds=timeout_seconds, + ) + provider = provider_metadata( + provider=embedding_provider, + model=embedding_model, + dimensions=len(query_embedding), + base_url=embedding_base_url, + ) + result["client_embedding"] = { + "provider": provider.get("embedding_provider"), + "model": embedding_model, + "stored_embedding_model": stored_model, + "dimensions": len(query_embedding), + "query_instruction": applied_instruction or None, + } + if refresh is not None: + result["embedding_refresh"] = { + "status": refresh.get("status"), + "counts": refresh.get("counts") or {}, + } + return result + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Search BSL code with a local or OpenAI-compatible embedding model." + ) + parser.add_argument("query") + parser.add_argument("--adapter-url", default=DEFAULT_ADAPTER_URL) + parser.add_argument("--base-id", required=True) + parser.add_argument("--limit", type=int, default=10) + parser.add_argument("--scan-limit", type=int, default=2000) + parser.add_argument( + "--embedding-provider", + default=LOCAL_HASHING_PROVIDER, + choices=[LOCAL_HASHING_PROVIDER, "openai-compatible"], + ) + parser.add_argument("--embedding-model", default=LOCAL_HASHING_MODEL) + parser.add_argument("--dimensions", type=int, default=384) + parser.add_argument("--embedding-base-url", default="") + parser.add_argument("--embedding-api-key-env", default="OPENAI_API_KEY") + parser.add_argument( + "--query-instruction", + default=None, + help=( + "Instruction prepended only to the query embedding. " + "Qwen3 Embedding gets a 1C-code retrieval instruction automatically; " + "pass an empty value to disable it." + ), + ) + parser.add_argument("--embed-pending", action="store_true") + parser.add_argument("--embed-limit", type=int, default=100) + parser.add_argument("--embed-batch-size", type=int, default=16) + parser.add_argument("--timeout-seconds", type=int, default=180) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + result = search_code_vectors( + adapter_url=args.adapter_url, + base_id=args.base_id, + query=args.query, + limit=args.limit, + scan_limit=args.scan_limit, + embedding_provider=args.embedding_provider, + embedding_model=args.embedding_model, + dimensions=args.dimensions, + embedding_base_url=args.embedding_base_url, + embedding_api_key_env=args.embedding_api_key_env, + query_instruction=args.query_instruction, + embed_pending=args.embed_pending, + embed_limit=args.embed_limit, + embed_batch_size=args.embed_batch_size, + timeout_seconds=args.timeout_seconds, + ) + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + print( + f"status={result.get('status')} matches={len(result.get('matches') or [])} " + f"model={(result.get('client_embedding') or {}).get('stored_embedding_model')}" + ) + for position, match in enumerate(result.get("matches") or [], start=1): + print( + f"{position}. score={float(match.get('score') or 0):.4f} " + f"object={match.get('object_ref')} routine={(match.get('chunk') or {}).get('routine_name')}" + ) + return 0 if result.get("status") in {"ok", "not_found"} else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke_1c_access_object.py b/scripts/smoke_1c_access_object.py index b9d7289..a9921d8 100644 --- a/scripts/smoke_1c_access_object.py +++ b/scripts/smoke_1c_access_object.py @@ -10,7 +10,7 @@ from typing import Any ROOT = Path(__file__).resolve().parents[1] -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" DEFAULT_REF = "РегистрСведений.УОП_АктуальныеСпецификации" DEFAULT_REPORT = ROOT / "reports" / "1c-access" / "upo_test-access-object.json" diff --git a/scripts/smoke_1c_access_snapshot.py b/scripts/smoke_1c_access_snapshot.py index 846e611..3df0eed 100644 --- a/scripts/smoke_1c_access_snapshot.py +++ b/scripts/smoke_1c_access_snapshot.py @@ -9,7 +9,7 @@ from typing import Any ROOT = Path(__file__).resolve().parents[1] -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" DEFAULT_REPORT = ROOT / "reports" / "1c-access" / "upo_test-access-snapshot-bsp.json" diff --git a/scripts/smoke_1c_code_write_saved_state.py b/scripts/smoke_1c_code_write_saved_state.py index 111214a..d6f77c2 100644 --- a/scripts/smoke_1c_code_write_saved_state.py +++ b/scripts/smoke_1c_code_write_saved_state.py @@ -9,7 +9,7 @@ import urllib.request from typing import Any -DEFAULT_ADAPTER_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_ADAPTER_URL = "http://docker.cin.su:8011" DEFAULT_MCP_URL = "http://docker.cin.su:8021" EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1" diff --git a/scripts/smoke_1c_extension_saved_state_prepare.py b/scripts/smoke_1c_extension_saved_state_prepare.py new file mode 100644 index 0000000..cf8f173 --- /dev/null +++ b/scripts/smoke_1c_extension_saved_state_prepare.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Controlled public smoke for extension ConfigCASSave preparation. + +The smoke uses no storage coordinates. It creates one extension saved-state +copy, verifies readback, rolls it back by opaque receipt, then proves that the +same public selector is immediately ready for another prepare. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +def rpc(base_url: str, method: str, payload: dict, timeout: float) -> dict: + request = Request( + base_url.rstrip("/") + "/rpc", + data=json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8"), + method="POST", + headers={"Content-Type": "application/json; charset=utf-8"}, + ) + try: + with urlopen(request, timeout=timeout) as response: + result = json.loads(response.read().decode("utf-8")) + except (HTTPError, URLError) as exc: + raise AssertionError(f"{method} transport failure: {exc}") from exc + if not isinstance(result, dict): + raise AssertionError(f"{method} returned a non-object response") + return result + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Public extension saved-state prepare/rollback smoke.") + parser.add_argument("--base-url", default="http://docker.cin.su:8011") + parser.add_argument("--base-id", default="upo_test") + parser.add_argument("--extension", default="фс_ДоработкиОбщее") + parser.add_argument("--ref", default="Catalog.Номенклатура") + parser.add_argument("--timeout", type=float, default=120.0) + parser.add_argument("--report", type=Path) + parser.add_argument("--apply", action="store_true", help="Perform the controlled SQL prepare and rollback.") + args = parser.parse_args() + report: dict = { + "schema": "onec_extension_saved_state_prepare_smoke.v1", + "base_url": args.base_url, + "base_id": args.base_id, + "extension": args.extension, + "ref": args.ref, + "status": "pending", + "passed": False, + } + target = { + "base_id": args.base_id, + "extension": args.extension, + "ref": args.ref, + "layer": "extension_saved_state", + } + try: + initial = rpc(args.base_url, "metadata.saved_state.prepare", target | {"mode": "plan"}, args.timeout) + report["initial_plan"] = {"status": initial.get("status"), "counts": initial.get("counts")} + require(initial.get("status") == "plan_ready", f"initial plan must be plan_ready, got {initial.get('status')}") + if not args.apply: + report["status"] = "plan_ready" + report["passed"] = True + else: + applied = rpc( + args.base_url, + "metadata.saved_state.prepare", + target | {"mode": "apply_and_verify", "allow_sql_saved_state_prepare": True}, + args.timeout, + ) + report["prepare"] = {"status": applied.get("status"), "counts": applied.get("counts"), "verification": applied.get("verification")} + require(applied.get("status") == "verified" and applied.get("applied") is True, "prepare must be verified") + receipt_id = str(applied.get("prepare_receipt_id") or "") + require(receipt_id, "prepare response must include an opaque receipt") + rolled_back = rpc( + args.base_url, + "metadata.saved_state.ensure.rollback", + {"base_id": args.base_id, "prepare_receipt_id": receipt_id, "allow_sql_saved_state_rollback": True}, + args.timeout, + ) + report["rollback"] = {"status": rolled_back.get("status"), "counts": rolled_back.get("counts")} + require(rolled_back.get("status") == "rolled_back" and rolled_back.get("applied") is True, "prepare rollback must succeed") + started = time.monotonic() + final_plan = rpc(args.base_url, "metadata.saved_state.prepare", target | {"mode": "plan"}, args.timeout) + elapsed_ms = round((time.monotonic() - started) * 1000, 1) + report["post_rollback_plan"] = {"status": final_plan.get("status"), "counts": final_plan.get("counts"), "elapsed_ms": elapsed_ms} + require(final_plan.get("status") == "plan_ready", f"post-rollback plan must be plan_ready, got {final_plan.get('status')}") + require(int((final_plan.get("counts") or {}).get("existing_saved_records") or 0) == 0, "rollback must leave no saved records") + report["status"] = "verified_and_rolled_back" + report["passed"] = True + except Exception as exc: + report["status"] = "failed" + report["error"] = str(exc) + 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") + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/smoke_1c_mcp_selector_chain.py b/scripts/smoke_1c_mcp_selector_chain.py index 3b511e6..0595539 100644 --- a/scripts/smoke_1c_mcp_selector_chain.py +++ b/scripts/smoke_1c_mcp_selector_chain.py @@ -38,6 +38,9 @@ LIVE_HELP_SELECTOR_METHODS = ( WORKING_STATE_METHODS = { "extension.objects.find", "metadata.resolve_overrides", + "metadata.object.forms", + "metadata.object.form.details", + "metadata.form.decode", "modules.search", "code.search", } @@ -863,8 +866,16 @@ def build_live_report( ) if write_plan.get("allowed") is not True: issues.append({"code": "live_composed_write_plan_not_allowed", "status": write_plan.get("status"), "problems": write_plan.get("problems")}) - if route.get("apply_method") != "metadata.module.write_apply": + if route.get("apply_method") not in {"metadata.module.write_apply", "code.write"}: issues.append({"code": "live_composed_write_plan_apply_method_mismatch", "actual": route.get("apply_method")}) + if hint.get("method") and hint.get("method") != route.get("apply_method"): + issues.append( + { + "code": "live_composed_write_plan_hint_method_mismatch", + "route_method": route.get("apply_method"), + "hint_method": hint.get("method"), + } + ) if hint.get("ready_for_apply_method") is not True: issues.append({"code": "live_composed_write_plan_hint_not_ready", "hint": hint}) if write_plan_target.get("module_ref") and hint_payload.get("module_ref") != write_plan_target.get("module_ref"): @@ -1107,6 +1118,45 @@ def selector_chain_examples() -> list[dict[str, Any]]: }, ], }, + { + "name": "report_to_separate_form_description", + "steps": [ + { + "tool": "onec_request", + "method": "metadata.object.forms", + "payload": { + "base_id": "", + "ref": "Report.", + "source_state": "working", + }, + "next": "Forms are references of the report. Select a returned public form name; do not treat the report card as the form description.", + }, + { + "tool": "onec_request", + "method": "metadata.object.form.details", + "payload": { + "base_id": "", + "ref": "Report.", + "form": "", + "source_state": "working", + "include_parameters": True, + }, + "next": "Read the separate form description: attributes, parameters, commands, items, and form module summary. A command has no module; its handler is a routine in this form module when decoded evidence provides the link.", + }, + { + "tool": "onec_request", + "method": "metadata.form.decode", + "payload": { + "base_id": "", + "ref": "Report.", + "form": "", + "source_state": "working", + "view": "structure", + }, + "next": "Use the static structure projection only. Parent/child edges remain unresolved unless the adapter reports proven codec evidence.", + }, + ], + }, ] @@ -1180,7 +1230,7 @@ def main() -> int: parser.add_argument("--no-report", action="store_true", help="Do not write a report file.") parser.add_argument("--live", action="store_true", help="Run optional live adapter smoke through /rpc.") parser.add_argument("--transport", choices=("rest", "mcp"), default="rest", help="Live smoke transport.") - parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011", help="1C REST adapter base URL for --live.") + parser.add_argument("--adapter-url", default="http://docker.cin.su:8011", help="1C REST adapter base URL for --live.") parser.add_argument("--mcp-url", default="http://docker.cin.su:8021", help="1C MCP proxy base URL for --live --transport mcp.") parser.add_argument("--base-id", help="Concrete 1C base id for --live.") parser.add_argument("--ref", help="Optional concrete public object ref for deterministic --live checks, for example Kind.ObjectName.") diff --git a/scripts/smoke_1c_saved_state_changes.py b/scripts/smoke_1c_saved_state_changes.py index e81f5a1..c14d534 100644 --- a/scripts/smoke_1c_saved_state_changes.py +++ b/scripts/smoke_1c_saved_state_changes.py @@ -9,7 +9,7 @@ from typing import Any from urllib.error import HTTPError, URLError -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" DEFAULT_MCP_URL = "http://docker.cin.su:8021" EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1" diff --git a/scripts/smoke_1c_saved_state_diff.py b/scripts/smoke_1c_saved_state_diff.py index 13192f2..7bca87c 100644 --- a/scripts/smoke_1c_saved_state_diff.py +++ b/scripts/smoke_1c_saved_state_diff.py @@ -9,7 +9,7 @@ from typing import Any from urllib.error import HTTPError, URLError -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" DEFAULT_MCP_URL = "http://docker.cin.su:8021" EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1" diff --git a/scripts/smoke_1c_saved_state_module_write.py b/scripts/smoke_1c_saved_state_module_write.py index 3f17c0b..02ea27a 100644 --- a/scripts/smoke_1c_saved_state_module_write.py +++ b/scripts/smoke_1c_saved_state_module_write.py @@ -3,12 +3,13 @@ from __future__ import annotations import argparse import json +import re import urllib.request from pathlib import Path from typing import Any -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" def rpc(base_url: str, method: str, payload: dict[str, Any]) -> dict[str, Any]: @@ -49,10 +50,15 @@ def discover_module_target(base_url: str, base_id: str, table: str, timeout_seco if not file_name: continue for stream in module.get("streams") or []: - if not isinstance(stream, dict) or not stream.get("has_bsl_marker"): + if not isinstance(stream, dict): continue preview = str(stream.get("preview") or "") - old = "#Если " if "#Если " in preview else "Процедура " if "Процедура " in preview else "" + if not stream.get("has_bsl_marker") and not any( + marker in preview for marker in ("Процедура ", "Функция ", "&НаКлиенте", "&НаСервере") + ): + continue + declaration = re.search(r"(?im)^\s*(?:Процедура|Функция)\s+[A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*\s*\([^)]*\)", preview) + old = declaration.group(0).strip() if declaration else "#Если " if "#Если " in preview else "" if not old: continue write_plan_target = stream.get("write_plan_target") if isinstance(stream.get("write_plan_target"), dict) else {} @@ -77,6 +83,11 @@ def main() -> int: parser.add_argument("--base-id", default="upo_test") parser.add_argument("--table", default="ConfigCASSave") parser.add_argument("--file-name", default="", help="Saved-state module file name. Empty or placeholder* auto-selects a BSL stream.") + parser.add_argument( + "--module-ref", + default="", + help="Exact module_ref returned by discovery. Useful for embedded form modules without a #stream suffix.", + ) parser.add_argument("--stream-index", type=int, default=4) parser.add_argument("--old", default="Перем Параметры; ") parser.add_argument("--new", default="Перем Параметры; ") @@ -115,13 +126,29 @@ def main() -> int: if auto_target.get("expected_sha1") and not args.expected_sha1: args.expected_sha1 = str(auto_target["expected_sha1"]) - module_ref = f"{args.table}:{args.file_name}#stream:{args.stream_index}" + module_ref = str( + args.module_ref + or auto_target.get("module_ref") + or f"{args.table}:{args.file_name}#stream:{args.stream_index}" + ) + saved_state = rpc( + args.base_url, + "metadata.saved_state.modules.search", + { + "base_id": args.base_id, + "tables": [args.table], + "file_name": args.file_name, + "limit": 1, + "scan_limit": 10, + "include_storage": True, + "timeout_seconds": args.timeout_seconds, + }, + ) + saved_state_preflight = { + "status": saved_state.get("status"), + "counts": saved_state.get("counts") or {}, + } if args.allow_empty_saved_state: - saved_state = rpc( - args.base_url, - "metadata.saved_state.modules.search", - {"base_id": args.base_id, "tables": [args.table], "limit": 1, "scan_limit": 100, "timeout_seconds": args.timeout_seconds}, - ) if saved_state.get("status") == "ok" and ((saved_state.get("counts") or {}).get("modules") or 0) == 0: result = { "schema": "onec_module_stream_write_smoke.v1", @@ -157,6 +184,10 @@ def main() -> int: result = { "schema": "onec_module_stream_write_smoke.v1", "status": "write_failed", + "base_id": args.base_id, + "table": args.table, + "module_ref": module_ref, + "saved_state_preflight": saved_state_preflight, "metadata_write": written, "write_plan": write_plan, } @@ -185,7 +216,7 @@ def main() -> int: "base_id": args.base_id, "table": args.table, "module_ref": module_ref, - **({"saved_state_preflight": auto_target.get("discovery")} if auto_target else {}), + "saved_state_preflight": saved_state_preflight, "write_plan": { "status": write_plan.get("status"), "allowed": write_plan.get("allowed"), diff --git a/scripts/smoke_1c_saved_state_write_routes.py b/scripts/smoke_1c_saved_state_write_routes.py index d4ed1b1..66939ff 100644 --- a/scripts/smoke_1c_saved_state_write_routes.py +++ b/scripts/smoke_1c_saved_state_write_routes.py @@ -290,7 +290,7 @@ def discover_smoke_routes(base_url: str, base_id: str, table: str, timeout: floa def main() -> int: parser = argparse.ArgumentParser(description="Smoke test saved-state form write routing with apply_and_rollback.") - parser.add_argument("--base-url", default="http://docker-gpu.cin.su:8011", help="1C REST adapter URL.") + parser.add_argument("--base-url", default="http://docker.cin.su:8011", help="1C REST adapter URL.") parser.add_argument("--base-id", default="upo_test", help="Configured adapter base id.") parser.add_argument("--table", default="ConfigCASSave", help="Saved-state SQL table.") parser.add_argument("--file-name", default=DEFAULT_FORM_FILE, help="Saved-state form file name. Omit to auto-select a safe saved-state form title route.") diff --git a/scripts/smoke_1c_write_matrix.py b/scripts/smoke_1c_write_matrix.py index 3cd42cd..4a0f38d 100644 --- a/scripts/smoke_1c_write_matrix.py +++ b/scripts/smoke_1c_write_matrix.py @@ -86,7 +86,7 @@ def compact_smoke_result(smoke: dict[str, Any], *, max_failures: int) -> dict[st def main() -> int: parser = argparse.ArgumentParser(description="Build and optionally smoke-test saved-state form write matrix.") - parser.add_argument("--base-url", default="http://docker-gpu.cin.su:8011", help="1C REST adapter URL.") + parser.add_argument("--base-url", default="http://docker.cin.su:8011", help="1C REST adapter URL.") parser.add_argument("--base-id", default="upo_test", help="Configured adapter base id.") parser.add_argument("--table", default="ConfigCASSave", help="Saved-state SQL table.") parser.add_argument("--file-name", default=DEFAULT_FORM_FILE, help="Saved-state form file name.") diff --git a/scripts/smoke_1c_write_plan_safety.py b/scripts/smoke_1c_write_plan_safety.py index f467d95..3689cb5 100644 --- a/scripts/smoke_1c_write_plan_safety.py +++ b/scripts/smoke_1c_write_plan_safety.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" DEFAULT_MCP_URL = "http://docker.cin.su:8021" EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1" diff --git a/scripts/smoke_1c_write_preflight.py b/scripts/smoke_1c_write_preflight.py index 7ee573d..8126bb8 100644 --- a/scripts/smoke_1c_write_preflight.py +++ b/scripts/smoke_1c_write_preflight.py @@ -10,7 +10,7 @@ from typing import Any from urllib.error import HTTPError, URLError -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" DEFAULT_MCP_URL = "http://docker.cin.su:8021" EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1" PREFLIGHT_CLASSIFICATION_STATUSES = { diff --git a/scripts/smoke_1c_write_rollback_safety.py b/scripts/smoke_1c_write_rollback_safety.py index 3f12096..c14ad9a 100644 --- a/scripts/smoke_1c_write_rollback_safety.py +++ b/scripts/smoke_1c_write_rollback_safety.py @@ -9,7 +9,7 @@ from typing import Any from urllib.error import HTTPError, URLError -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" DEFAULT_MCP_URL = "http://docker.cin.su:8021" EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1" diff --git a/scripts/track_1c_template_history.py b/scripts/track_1c_template_history.py index 08c3b60..1b31c78 100644 --- a/scripts/track_1c_template_history.py +++ b/scripts/track_1c_template_history.py @@ -120,7 +120,7 @@ def build_history( def main() -> int: parser = argparse.ArgumentParser(description="Track recent MOXCEL template signatures from live ConfigCAS.") - parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011") + parser.add_argument("--adapter-url", default="http://docker.cin.su:8011") parser.add_argument("--base-id", default="upo_test") parser.add_argument("--limit", type=int, default=12) parser.add_argument("--track-name", default="R7C2_TEST") diff --git a/scripts/verify_1c_adapter_deployment.ps1 b/scripts/verify_1c_adapter_deployment.ps1 index 48e6242..746e3be 100644 --- a/scripts/verify_1c_adapter_deployment.ps1 +++ b/scripts/verify_1c_adapter_deployment.ps1 @@ -1,7 +1,7 @@ param( [Parameter(Mandatory = $true)] [string[]]$BaseId, - [string]$AdapterUrl = "http://docker-gpu.cin.su:8011", + [string]$AdapterUrl = "http://docker.cin.su:8011", [string]$McpUrl = "http://docker.cin.su:8021", [string]$ObjectRef, [string]$ObjectKind, @@ -554,6 +554,28 @@ try { Assert-SavedStateChangesReport -Label "REST adapter saved-state changes smoke ($currentBaseId)" -Path $savedStateChangesReport } + # This is the regression for the extension first-write path. It + # is deliberately restricted to the disposable authorised base: + # the smoke creates one ConfigCASSave row and removes it again by + # the opaque receipt before returning. + if ($currentBaseId -eq "upo_test") { + $extensionPrepareReport = Join-Path $reportDir "extension-saved-state-prepare-smoke.json" + $extensionPrepareCommand = @( + "python", + "scripts/smoke_1c_extension_saved_state_prepare.py", + "--base-url", + $AdapterUrl, + "--base-id", + $currentBaseId, + "--timeout", + $TimeoutSec.ToString(), + "--report", + $extensionPrepareReport, + "--apply" + ) + Invoke-CheckedCommand -Label "REST adapter extension saved-state prepare smoke ($currentBaseId)" -Command $extensionPrepareCommand + } + if (-not $SkipSavedStateWriteSmoke) { $readinessReport = Join-Path $reportDir "saved-state-strict-readiness.json" $readinessCommand = @( diff --git a/scripts/verify_1c_saved_state_copy.py b/scripts/verify_1c_saved_state_copy.py index c97b227..26c892c 100644 --- a/scripts/verify_1c_saved_state_copy.py +++ b/scripts/verify_1c_saved_state_copy.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any -DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011" +DEFAULT_BASE_URL = "http://docker.cin.su:8011" SOURCE_BY_TARGET = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"} diff --git a/scripts/watch_1c_moxel_property_experiment.py b/scripts/watch_1c_moxel_property_experiment.py index 8db10b8..7c5691b 100644 --- a/scripts/watch_1c_moxel_property_experiment.py +++ b/scripts/watch_1c_moxel_property_experiment.py @@ -141,7 +141,7 @@ def wait_for_new_moxel( def main() -> int: parser = argparse.ArgumentParser(description="Capture a before/after MOXCEL one-property experiment around a manual 1C save.") - parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011") + parser.add_argument("--adapter-url", default="http://docker.cin.su:8011") parser.add_argument("--base-id", default="upo_test") parser.add_argument("--property", required=True, help="Property label, for example ВертикальноеПоложение.") parser.add_argument("--operation", default="manual_one_property_save") diff --git a/tests/1c/test_adapter_observer.py b/tests/1c/test_adapter_observer.py new file mode 100644 index 0000000..65ce61c --- /dev/null +++ b/tests/1c/test_adapter_observer.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + + +MODULE = Path(__file__).parents[2] / "plugins" / "1c" / "observer" / "observer_server.py" +SPEC = importlib.util.spec_from_file_location("adapter_observer", MODULE) +assert SPEC and SPEC.loader +observer = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(observer) + + +def test_summary_separates_expected_rejection_and_exception() -> None: + events = [ + {"event": "adapter_rpc", "time": "2026-08-07T10:00:00Z", "request_id": "a", "method": "metadata.form.decode", "request": {"base_id": "upo_test", "ref": "Document.X"}, "status": "ok", "duration_ms": 100}, + {"event": "adapter_rpc", "time": "2026-08-07T10:01:00Z", "request_id": "b", "method": "metadata.form.decode", "request": {"base_id": "upo_test"}, "status": "partial", "error": "time_budget_exhausted", "duration_ms": 6000}, + {"event": "adapter_rpc", "time": "2026-08-07T10:02:00Z", "request_id": "c", "method": "modules.read", "request": {"base_id": "upo_test"}, "status": "exception", "error": "request_exception", "duration_ms": 10}, + {"event": "adapter_rpc", "time": "2026-08-07T10:03:00Z", "request_id": "d", "method": "adapter.job.get", "request": {"base_id": "upo_test"}, "status": "running", "duration_ms": 1}, + ] + result = observer.build_summary(events, 0) + assert result["events"] == 4 + assert result["exceptions"] == 1 + assert result["p95_ms"] == 6000 + assert any(item["error"] == "time_budget_exhausted" for item in result["findings"]) + assert not any(item["status"] == "running" for item in result["findings"]) + assert "BSL" not in json.dumps(observer.event_view(events[0]), ensure_ascii=False) + + +def test_correlations_keep_mcp_request_that_did_not_reach_rest() -> None: + rest = [{"request_id": "same", "method": "modules.read", "status": "ok", "duration_ms": 20}] + mcp = [{"request_id": "same", "method": "modules.read", "status": "ok", "duration_ms": 30}, {"request_id": "lost", "method": "modules.read", "status": "exception", "error": "adapter_unavailable"}] + result = observer.correlations(rest, mcp) + assert result[0]["correlation_status"] == "not_reached_rest" + assert result[1]["correlation_status"] == "matched" diff --git a/tests/1c/test_additional_attributes.py b/tests/1c/test_additional_attributes.py new file mode 100644 index 0000000..f6a2cea --- /dev/null +++ b/tests/1c/test_additional_attributes.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +CONNECTOR = ROOT / "plugins" / "1c" / "connector" +if str(CONNECTOR) not in sys.path: + sys.path.insert(0, str(CONNECTOR)) + +import adapter_1c_server as adapter # noqa: E402 + + +def test_additional_attributes_find_reports_empty_source_not_silent_empty(monkeypatch) -> None: + monkeypatch.setattr(adapter, "require_base_id", lambda _payload, _method: "upo_test") + monkeypatch.setattr( + adapter, + "data_read", + lambda _payload, method: { + "status": "ok", + "object": {"ref": "ChartOfCharacteristicTypes.ДополнительныеРеквизитыИСведения"}, + "rows": [], + "counts": {"rows": 0}, + "route_diagnostics": {"status": "empty_source", "source_count": 0}, + }, + ) + + result = adapter.additional_attributes_find({"base_id": "upo_test", "query": "Ответственное направление"}) + + assert result["status"] == "empty_source" + assert result["properties"] == [] + assert result["diagnostics"]["route"]["source_count"] == 0 + + +def test_additional_attributes_storage_refuses_to_guess_storage_route() -> None: + result = adapter.additional_attributes_storage_resolve( + { + "base_id": "upo_test", + "property_ref": "0123456789ABCDEF0123456789ABCDEF", + "owner_ref": "Catalog.СтруктурныеЕдиницы", + } + ) + + assert result["status"] == "unresolved" + assert result["storage"] is None + assert "will not infer" in result["diagnostics"]["message"] + + +def test_additional_attributes_storage_confirms_metadata_evidenced_route(monkeypatch) -> None: + monkeypatch.setattr(adapter, "list_objects", lambda *_args, **_kwargs: {"objects": [{"ref": "InformationRegister.ДополнительныеСведения"}]}) + monkeypatch.setattr( + adapter, + "metadata_object_attributes", + lambda _payload: { + "status": "ok", + "dimensions": [ + {"name": "Объект", "type": {"kind": "reference"}}, + {"name": "Свойство", "type": {"resolved": {"kind": "ChartOfCharacteristicTypes", "name": "ДополнительныеРеквизитыИСведения"}}}, + ], + "resources": [{"name": "Значение", "type": {"kind": "composite"}}], + }, + ) + + result = adapter.additional_attributes_storage_resolve( + { + "base_id": "upo_test", + "property_ref": "0123456789ABCDEF0123456789ABCDEF", + "owner_ref": "Catalog.СтруктурныеЕдиницы", + } + ) + + assert result["status"] == "confirmed" + assert result["storage"]["source_ref"] == "InformationRegister.ДополнительныеСведения" + assert result["storage"]["fields"] == {"object": "Объект", "property": "Свойство", "value": "Значение"} + assert result["scd_join"]["parameters"]["Свойство"] == "0123456789ABCDEF0123456789ABCDEF" + + +def test_additional_attributes_storage_requires_public_inputs() -> None: + result = adapter.additional_attributes_storage_resolve({"base_id": "upo_test", "owner_ref": "Catalog.СтруктурныеЕдиницы"}) + + assert result["status"] == "invalid_argument" + assert result["argument"] == "property_ref" + + +def test_chart_semantics_exposes_only_evidenced_chart_level_value_types(monkeypatch) -> None: + monkeypatch.setattr(adapter, "config_tree_item_at_path", lambda _tree, path: {"path": path}) + monkeypatch.setattr(adapter, "public_pattern_value_type", lambda *_args, **_kwargs: {"kind": "union", "count": 2}) + + result = adapter.chart_of_characteristic_types_sql_details("upo_test", {"type": "list"}, table="Config", timeout_seconds=30) + + assert result["allowed_value_types"] == {"kind": "union", "count": 2} + assert result["evidence"]["config_path"] == "1.18" + + +def test_chart_data_list_accepts_description_contains_filter(monkeypatch) -> None: + monkeypatch.setattr( + adapter, + "data_object_schema", + lambda _payload: { + "status": "ok", + "base_id": "upo_test", + "table": {"name": "_Chrc1"}, + "object": {"kind": "ChartOfCharacteristicTypes"}, + "fields": [ + {"name": "ref", "physical_name": "_IDRRef", "type": {}}, + {"name": "description", "physical_name": "_Description", "type": {}}, + {"name": "marked_for_deletion", "physical_name": "_Marked", "type": {}}, + ], + }, + ) + captured: dict[str, object] = {} + + def fake_sql(_base_id, sql, params, **_kwargs): + captured["sql"] = sql + captured["params"] = params + return [], None + + monkeypatch.setattr(adapter, "data_sql_rows", fake_sql) + result = adapter.data_read( + { + "base_id": "upo_test", + "ref": "ChartOfCharacteristicTypes.Тест", + "fields": ["ref", "description"], + "filters": {"description": {"operator": "contains", "value": "Ответственное направление"}}, + } + ) + + assert result["status"] == "ok" + assert "[_Description] LIKE %s" in str(captured["sql"]) + assert "%Ответственное направление%" in captured["params"] + + +def test_password_test_mode_accepts_explicit_global_unauthenticated_profile(monkeypatch) -> None: + monkeypatch.delenv("ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED", raising=False) + monkeypatch.setenv("ONEC_ADAPTER_ALLOW_UNAUTHENTICATED_ADMIN", "true") + + assert adapter.infobase_user_password_unauthenticated_test_mode() is True diff --git a/tests/1c/test_code_vector_embedding_worker.py b/tests/1c/test_code_vector_embedding_worker.py new file mode 100644 index 0000000..ca68407 --- /dev/null +++ b/tests/1c/test_code_vector_embedding_worker.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) + +import embed_1c_code_vectors as worker # noqa: E402 + + +def test_embed_pending_code_vectors_upserts_with_precondition(monkeypatch) -> None: + calls: list[tuple[str, dict]] = [] + + def fake_adapter_call( + adapter_url: str, + method: str, + payload: dict, + *, + timeout_seconds: int = 180, + ) -> dict: + calls.append((method, payload)) + if method == "metadata.code_vector.pending": + assert payload["embedding_model"] == "local-hashing-v1" + assert payload["chunk_kinds"] == ["routine"] + assert payload["max_text_chars"] == 4000 + return { + "status": "ok", + "chunks": [ + { + "chunk_id": "chunk-1", + "text_sha1": "a" * 40, + "text": "Процедура РассчитатьНалог()", + } + ], + } + if method == "metadata.code_vector.embedding.upsert": + assert payload["chunk_id"] == "chunk-1" + assert payload["text_sha1"] == "a" * 40 + assert payload["embedding_model"] == "local-hashing-v1" + assert len(payload["embedding"]) == 16 + return {"status": "ok", "dimensions": 16} + raise AssertionError(method) + + monkeypatch.setattr(worker, "adapter_call", fake_adapter_call) + + result = worker.embed_pending_code_vectors( + adapter_url="http://adapter/rpc", + base_id="upo_test", + limit=1, + dimensions=16, + ) + + assert result["status"] == "ok" + assert result["embedding"]["dimensions"] == 16 + assert result["counts"] == { + "pending": 1, + "processed": 1, + "stored": 1, + "conflicts": 0, + "skipped": 0, + "errors": 0, + } + assert [method for method, _payload in calls] == [ + "metadata.code_vector.pending", + "metadata.code_vector.embedding.upsert", + ] + + +def test_embed_pending_code_vectors_dry_run_does_not_upsert(monkeypatch) -> None: + calls: list[str] = [] + + def fake_adapter_call( + adapter_url: str, + method: str, + payload: dict, + *, + timeout_seconds: int = 180, + ) -> dict: + calls.append(method) + return { + "status": "ok", + "chunks": [ + { + "chunk_id": "chunk-1", + "text_sha1": "b" * 40, + "text": "Функция НайтиОбъект()", + } + ], + } + + monkeypatch.setattr(worker, "adapter_call", fake_adapter_call) + + result = worker.embed_pending_code_vectors( + adapter_url="http://adapter/rpc", + base_id="upo_test", + dimensions=8, + dry_run=True, + ) + + assert result["counts"]["processed"] == 1 + assert result["counts"]["stored"] == 0 + assert result["upserts"][0]["status"] == "dry_run" + assert calls == ["metadata.code_vector.pending"] diff --git a/tests/1c/test_code_vector_search_cli.py b/tests/1c/test_code_vector_search_cli.py new file mode 100644 index 0000000..595ce36 --- /dev/null +++ b/tests/1c/test_code_vector_search_cli.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) + +import search_1c_code_vectors as search_cli # noqa: E402 + + +def test_search_code_vectors_sends_model_and_strict_query_embedding(monkeypatch) -> None: + calls: list[tuple[str, dict]] = [] + + monkeypatch.setattr( + search_cli, + "embed_texts", + lambda texts, **kwargs: [[1.0, 0.0, 0.0]], + ) + + def fake_adapter_call( + adapter_url: str, + method: str, + payload: dict, + *, + timeout_seconds: int = 180, + ) -> dict: + calls.append((method, payload)) + return {"status": "ok", "matches": []} + + monkeypatch.setattr(search_cli, "adapter_call", fake_adapter_call) + + result = search_cli.search_code_vectors( + adapter_url="http://adapter/rpc", + base_id="upo_test", + query="где рассчитывается налог", + embedding_provider="openai-compatible", + embedding_model="test-code-model", + embedding_base_url="http://embeddings", + dimensions=3, + ) + + assert result["status"] == "ok" + assert result["client_embedding"]["stored_embedding_model"] == "openai-compatible:test-code-model@d3" + assert calls == [ + ( + "metadata.code_vector.search", + { + "base_id": "upo_test", + "query": "где рассчитывается налог", + "query_embedding": [1.0, 0.0, 0.0], + "embedding_model": "openai-compatible:test-code-model@d3", + "limit": 10, + "scan_limit": 2000, + "verify": True, + "strict": True, + }, + ) + ] + + +def test_qwen3_search_uses_query_instruction_but_keeps_lexical_query_plain(monkeypatch) -> None: + embedded_texts: list[str] = [] + calls: list[tuple[str, dict]] = [] + + def fake_embed_texts(texts: list[str], **kwargs) -> list[list[float]]: + embedded_texts.extend(texts) + return [[0.5, 0.5]] + + monkeypatch.setattr(search_cli, "embed_texts", fake_embed_texts) + + def fake_adapter_call( + adapter_url: str, + method: str, + payload: dict, + *, + timeout_seconds: int = 180, + ) -> dict: + calls.append((method, payload)) + return {"status": "ok", "matches": []} + + monkeypatch.setattr(search_cli, "adapter_call", fake_adapter_call) + + result = search_cli.search_code_vectors( + adapter_url="http://adapter/rpc", + base_id="upo_test", + query="обработка проведения документа", + embedding_provider="openai-compatible", + embedding_model="qwen3-embedding-0.6b", + embedding_base_url="http://embeddings", + ) + + assert embedded_texts == [ + "Instruct: Given a natural-language software task, retrieve the relevant " + "1C Enterprise BSL source-code fragment that implements or explains it\n" + "Query:обработка проведения документа" + ] + assert calls[0][1]["query"] == "обработка проведения документа" + assert result["client_embedding"]["query_instruction"] == ( + search_cli.DEFAULT_QWEN3_CODE_RETRIEVAL_INSTRUCTION + ) diff --git a/tests/1c/test_configurator_refresh_guidance.py b/tests/1c/test_configurator_refresh_guidance.py new file mode 100644 index 0000000..4f7ee6a --- /dev/null +++ b/tests/1c/test_configurator_refresh_guidance.py @@ -0,0 +1,40 @@ +"""Regression checks for SQL saved-state → Configurator refresh guidance.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +CONNECTOR = Path(__file__).resolve().parents[2] / "plugins" / "1c" / "connector" +if str(CONNECTOR) not in sys.path: + sys.path.insert(0, str(CONNECTOR)) + +import adapter_1c_server as adapter # noqa: E402 + + +EXTENSION_FILE = "fb26cf42-7609-11f1-828f-005056b0d483__77494708-43ea-4956-ac3c-199cfb035ad2.0" + + +def test_existing_extension_saved_object_reopens_object() -> None: + result = adapter.saved_state_configurator_refresh_guidance( + table="ConfigCASSave", file_name=EXTENSION_FILE, object_existed_in_saved_state=True + ) + assert result["scope"] == "object" + assert result["action"] == "close_reopen_object" + + +def test_new_extension_saved_object_reopens_extension() -> None: + result = adapter.saved_state_configurator_refresh_guidance( + table="ConfigCASSave", file_name=EXTENSION_FILE, object_existed_in_saved_state=False + ) + assert result["scope"] == "extension" + assert result["action"] == "close_reopen_extension" + + +def test_new_base_saved_object_reopens_configuration() -> None: + result = adapter.saved_state_configurator_refresh_guidance( + table="ConfigSave", file_name="77494708-43ea-4956-ac3c-199cfb035ad2.0", object_existed_in_saved_state=False + ) + assert result["scope"] == "configuration" + assert result["action"] == "close_reopen_configuration" diff --git a/tests/1c/test_extension_common_form_route.py b/tests/1c/test_extension_common_form_route.py new file mode 100644 index 0000000..eb1d18e --- /dev/null +++ b/tests/1c/test_extension_common_form_route.py @@ -0,0 +1,62 @@ +"""Extension CommonForm decoding must use the manifest payload route, not its GUID as a file name.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +CONNECTOR = Path(__file__).resolve().parents[2] / "plugins" / "1c" / "connector" +if str(CONNECTOR) not in sys.path: + sys.path.insert(0, str(CONNECTOR)) + +import adapter_1c_server as adapter # noqa: E402 + + +def test_common_form_uses_extension_manifest_route(monkeypatch) -> None: + form_guid = "e7517ee6-7e33-40ba-9442-48c78e0818e1" + captured: dict[str, str] = {} + + monkeypatch.setattr( + adapter, + "extension_objects_find", + lambda _payload: { + "status": "ok", + "objects": [{ + "kind": "CommonForm", + "name": "ФормаОтчетаПродкорзина", + "guid": form_guid, + "route": {"table": "ConfigCAS", "file_name": "manifest-hash-key"}, + }], + }, + ) + + def fake_read(_base_id, table, file_name, **_kwargs): + captured["table"] = table + captured["file_name"] = file_name + return None, None, {"status": "source_missing", "diagnostics": {"message": "fixture stop"}} + + monkeypatch.setattr(adapter, "read_storage_file_bytes", fake_read) + + result = adapter.metadata_form_decode( + { + "base_id": "upo_test", + "kind": "CommonForm", + "extension": "фс_Отчеты1", + "name": "ФормаОтчетаПродкорзина", + "guid": form_guid, + "source_state": "active", + } + ) + + assert result["status"] == "source_missing" + assert captured == {"table": "ConfigCAS", "file_name": "manifest-hash-key"} + + +def test_static_form_structure_marks_unproven_tree_edges() -> None: + structure = adapter.form_static_structure( + {"items": [{"id": "1", "name": "Настройки", "type_name": "Группа", "path": "1.7", "depth": 2}]} + ) + assert structure["status"] == "partial" + assert structure["elements"][0]["parent"]["status"] == "unresolved" + assert structure["elements"][0]["children"]["status"] == "unresolved" diff --git a/tests/1c/test_extension_configinfo_map_codec.py b/tests/1c/test_extension_configinfo_map_codec.py new file mode 100644 index 0000000..7a496ae --- /dev/null +++ b/tests/1c/test_extension_configinfo_map_codec.py @@ -0,0 +1,193 @@ +"""Pure regression fixtures for the proven ConfigInfo file-SHA1 map rewrite.""" + +from __future__ import annotations + +import base64 +import hashlib +import sys +import zlib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +CONNECTOR = ROOT / "plugins" / "1c" / "connector" +PARSER = ROOT / "plugins" / "1c" +for path in (CONNECTOR, PARSER): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +import adapter_1c_server as adapter # noqa: E402 +from parser.payload import decode_payload_lossless # noqa: E402 + + +LOGICAL = "77494708-43ea-4956-ac3c-199cfb035ad2.0" + + +def raw_deflate(text: str) -> bytes: + encoder = zlib.compressobj(wbits=-zlib.MAX_WBITS) + return encoder.compress(text.encode("utf-8")) + encoder.flush() + + +def test_rewrites_only_named_file_sha1_reference() -> None: + original_file = b"old module bytes" + replacement_file = b"new module bytes" + old_ref = base64.b64encode(hashlib.sha1(original_file).digest()).decode("ascii") + service_atom = base64.b64encode(b"S" * 32).decode("ascii") + source_text = f'{{2,extension,{service_atom}}},{{7,"{LOGICAL}",{old_ref},"other.0",{old_ref}}}' + result = adapter.rewrite_extension_configinfo_file_sha1_reference( + raw_deflate(source_text), logical_file_name=LOGICAL, replacement_file_data=replacement_file + ) + assert result["status"] == "ok" + assert result["replacement_file_sha1"] == hashlib.sha1(replacement_file).hexdigest() + decoded = str(decode_payload_lossless(result["payload"]).get("text") or "") + assert service_atom in decoded + assert result["replacement_reference_base64"] in decoded + assert decoded.count(old_ref) == 1 # unrelated map entry was preserved + + +def test_rejects_ambiguous_logical_reference() -> None: + ref = base64.b64encode(hashlib.sha1(b"old").digest()).decode("ascii") + source_text = f'{{7,"{LOGICAL}",{ref},"{LOGICAL}",{ref}}}' + result = adapter.rewrite_extension_configinfo_file_sha1_reference( + raw_deflate(source_text), logical_file_name=LOGICAL, replacement_file_data=b"new" + ) + assert result["status"] == "reference_not_unique" + assert result["occurrences"] == 2 + + +def test_builds_paired_plan_only_when_map_matches_current_stream() -> None: + current = b"current module" + replacement = b"replacement module" + current_ref = base64.b64encode(hashlib.sha1(current).digest()).decode("ascii") + configinfo = raw_deflate(f'{{7,"{LOGICAL}",{current_ref}}}') + result = adapter.build_extension_saved_state_pair_plan( + logical_file_name=LOGICAL, + current_file_data=current, + replacement_file_data=replacement, + current_configinfo_data=configinfo, + ) + assert result["status"] == "ok" + assert result["module"]["replacement_sha1"] == hashlib.sha1(replacement).hexdigest() + assert result["configinfo"]["service_atoms"] == "preserved_unmodified" + + +def test_rejects_pair_when_map_points_to_another_stream() -> None: + mapped = b"other module" + current = b"current module" + mapped_ref = base64.b64encode(hashlib.sha1(mapped).digest()).decode("ascii") + configinfo = raw_deflate(f'{{7,"{LOGICAL}",{mapped_ref}}}') + result = adapter.build_extension_saved_state_pair_plan( + logical_file_name=LOGICAL, + current_file_data=current, + replacement_file_data=b"replacement", + current_configinfo_data=configinfo, + ) + assert result["status"] == "configinfo_reference_mismatch" + + +class FakeCursor: + def __init__(self, state: dict[str, bytes]) -> None: + self.state = state + self.rows: list[dict[str, object]] = [] + + def execute(self, sql: str, params: tuple[object, ...] | None = None) -> None: + if sql.startswith("SET LOCK_TIMEOUT"): + return + if sql.startswith("SELECT FileName, PartNo, BinaryData"): + assert params is not None + self.rows = [ + {"FileName": name, "PartNo": 0, "BinaryData": self.state[name]} + for name in params + if name in self.state + ] + return + if sql.startswith("UPDATE dbo.ConfigCASSave"): + assert params is not None + data, _size, name, _part = params + self.state[str(name)] = bytes(data) + return + raise AssertionError(sql) + + def fetchall(self) -> list[dict[str, object]]: + return self.rows + + +class FakeConnection: + def __init__(self, state: dict[str, bytes]) -> None: + self.cursor_instance = FakeCursor(state) + self.committed = False + self.rolled_back = False + self.closed = False + + def cursor(self, **_kwargs: object) -> FakeCursor: + return self.cursor_instance + + def commit(self) -> None: + self.committed = True + + def rollback(self) -> None: + self.rolled_back = True + + def close(self) -> None: + self.closed = True + + +def install_fake_pair_sql(monkeypatch: object, state: dict[str, bytes], connection: FakeConnection) -> None: + monkeypatch.setattr(adapter, "connect_live_sql", lambda *_args, **_kwargs: (connection, {"database": "test"}, None)) + monkeypatch.setattr( + adapter, + "read_storage_file_bytes", + lambda _base, _table, name, **_kwargs: (state.get(name), {"database": "test"}, None if name in state else {"status": "missing"}), + ) + monkeypatch.setattr(adapter, "write_storage_apply_backup", lambda **_kwargs: {"backup_id": "test"}) + monkeypatch.setattr(adapter, "invalidate_adapter_caches_after_saved_state_change", lambda *_args, **_kwargs: {"invalidated": True}) + + +def test_atomically_updates_module_and_configinfo(monkeypatch: object) -> None: + extension = "fb26cf42-7609-11f1-828f-005056b0d483" + module_name = f"{extension}__{LOGICAL}" + configinfo_name = f"{extension}__configinfo" + current = b"current module" + current_ref = base64.b64encode(hashlib.sha1(current).digest()).decode("ascii") + state = {module_name: current, configinfo_name: raw_deflate(f'{{7,"{LOGICAL}",{current_ref}}}')} + connection = FakeConnection(state) + install_fake_pair_sql(monkeypatch, state, connection) + + result = adapter.apply_extension_saved_state_pair( + "upo_test", + module_file_name=module_name, + replacement_module_data=b"new module", + expected_module_sha1=hashlib.sha1(current).hexdigest(), + proposal={"method": "test"}, + ) + + assert result["status"] == "applied" + assert result["applied"] is True + assert connection.committed is True + assert result["configurator_refresh"]["action"] == "close_reopen_object" + map_text = str(decode_payload_lossless(state[configinfo_name]).get("text") or "") + assert base64.b64encode(hashlib.sha1(b"new module").digest()).decode("ascii") in map_text + + +def test_atomic_writer_rolls_back_when_configinfo_map_diverges(monkeypatch: object) -> None: + extension = "fb26cf42-7609-11f1-828f-005056b0d483" + module_name = f"{extension}__{LOGICAL}" + configinfo_name = f"{extension}__configinfo" + current = b"current module" + wrong_ref = base64.b64encode(hashlib.sha1(b"other module").digest()).decode("ascii") + state = {module_name: current, configinfo_name: raw_deflate(f'{{7,"{LOGICAL}",{wrong_ref}}}')} + connection = FakeConnection(state) + install_fake_pair_sql(monkeypatch, state, connection) + + result = adapter.apply_extension_saved_state_pair( + "upo_test", + module_file_name=module_name, + replacement_module_data=b"new module", + expected_module_sha1=hashlib.sha1(current).hexdigest(), + proposal={"method": "test"}, + ) + + assert result["status"] == "configinfo_reference_mismatch" + assert result["applied"] is False + assert connection.rolled_back is True + assert state[module_name] == current diff --git a/tests/1c/test_mcp_selector_policy.py b/tests/1c/test_mcp_selector_policy.py new file mode 100644 index 0000000..57ea45e --- /dev/null +++ b/tests/1c/test_mcp_selector_policy.py @@ -0,0 +1,102 @@ +"""Public MCP selectors must not leak storage coordinates to coding agents.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +MCP = Path(__file__).resolve().parents[2] / "plugins" / "1c" / "mcp" +if str(MCP) not in sys.path: + sys.path.insert(0, str(MCP)) + +import adapter_1c_mcp as mcp # noqa: E402 + + +def test_read_selector_is_replaced_with_opaque_token() -> None: + mcp.SELECTOR_TOKENS.clear() + result = mcp.publicize_read_selectors( + { + "read_selector": { + "method": "metadata.form.decode", + "base_id": "upo_test", + "table": "ConfigCAS", + "file_name": "hash-key", + "guid": "11111111-1111-1111-1111-111111111111", + } + } + ) + + selector = result["read_selector"] + assert selector["method"] == "metadata.form.decode" + assert selector["selector_token"].startswith("onecsel_") + assert "table" not in selector + assert "file_name" not in selector + + payload, error = mcp.resolve_selector_token("metadata.form.decode", {"selector_token": selector["selector_token"]}) + assert error is None + assert payload is not None + assert payload["table"] == "ConfigCAS" + assert payload["file_name"] == "hash-key" + assert payload["_selector_token_resolved"] is True + + +def test_public_result_hides_nested_technical_fields() -> None: + result = mcp.publicize_read_selectors( + {"object": {"name": "Тест", "guid": "private"}, "source": {"table": "ConfigSave"}} + ) + assert result == {"object": {"name": "Тест"}, "source": {}} + + +def test_selector_token_cannot_be_reused_for_another_method() -> None: + token = mcp.issue_selector_token({"method": "code.read", "base_id": "upo_test", "module_ref": "private"}) + payload, error = mcp.resolve_selector_token("metadata.form.decode", {"selector_token": token}) + assert payload is None + assert error is not None + assert error["error"] == "selector_token_method_mismatch" + + +def test_normal_agent_request_rejects_technical_coordinates() -> None: + blocked = mcp.normal_agent_technical_field_guardrail( + "metadata.form.decode", + {"base_id": "upo_test", "table": "ConfigCASSave", "file_name": "private"}, + ) + assert blocked is not None + assert blocked["reason"] == "technical_selector_forbidden" + assert blocked["diagnostics"]["fields"] == ["file_name", "table"] + + +def test_normal_agent_request_rejects_nested_technical_coordinates() -> None: + blocked = mcp.normal_agent_technical_field_guardrail( + "metadata.form.decode", + {"base_id": "upo_test", "selector": {"table": "ConfigCASSave"}}, + ) + assert blocked is not None + assert blocked["diagnostics"]["fields"] == ["table"] + + +def test_diagnostic_request_may_keep_technical_coordinates(monkeypatch) -> None: + monkeypatch.setenv("ONEC_MCP_ALLOW_DIAGNOSTIC", "true") + assert mcp.normal_agent_technical_field_guardrail( + "metadata.form.decode", + {"base_id": "upo_test", "table": "ConfigCASSave", "diagnostic": True}, + ) is None + + +def test_form_methods_receive_effective_working_state() -> None: + payload = mcp.apply_freshness_request_policy( + {"base_id": "upo_test", "ref": "ОбщаяФорма.Тест"}, + "metadata.form.decode", + ) + assert payload["configuration_view"] == "effective_working" + assert payload["source_state"] == "working" + assert payload["state"] == "working" + + +def test_runtime_form_inspection_is_explicitly_unsupported() -> None: + result = mcp.runtime_form_inspection_unsupported( + "runtime.form.elements.inspect", + {"base_id": "upo_test"}, + ) + assert result is not None + assert result["error"] == "runtime_inspection_unsupported" diff --git a/tests/1c/test_metadata_object_components.py b/tests/1c/test_metadata_object_components.py new file mode 100644 index 0000000..ebf7ff7 --- /dev/null +++ b/tests/1c/test_metadata_object_components.py @@ -0,0 +1,58 @@ +"""Component graph must expose only children confirmed by its source readers.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +CONNECTOR = Path(__file__).resolve().parents[2] / "plugins" / "1c" / "connector" +if str(CONNECTOR) not in sys.path: + sys.path.insert(0, str(CONNECTOR)) + +import adapter_1c_server as adapter # noqa: E402 + + +def test_components_aggregate_confirmed_children_without_inference(monkeypatch) -> None: + object_card = {"ref": "Report.Sales", "kind": "Report", "name": "Sales", "guid": "a" * 36} + monkeypatch.setattr( + adapter, + "metadata_object_modules", + lambda _: {"status": "ok", "method": "metadata.object.modules", "object": object_card, + "modules": [{"role": "object_module", "name": "Модуль объекта", "path": "Report.Sales.МодульОбъекта"}]}, + ) + monkeypatch.setattr( + adapter, + "metadata_object_forms", + lambda _: {"status": "ok", "method": "metadata.object.forms", "object": object_card, + "forms": [{"name": "Main", "guid": "b" * 36}]}, + ) + monkeypatch.setattr( + adapter, + "metadata_object_templates", + lambda _: {"status": "ok", "method": "metadata.object.templates", "object": object_card, + "templates": [{"name": "ОсновнаяСхемаКомпоновкиДанных", "guid": "c" * 36, "kind": "DataCompositionSchema"}]}, + ) + + result = adapter.metadata_object_components( + {"base_id": "upo_test", "extension": "test2", "ref": "Report.Sales", "include_storage": False} + ) + + assert result["status"] == "ok" + assert [item["component_kind"] for item in result["components"]] == ["metadata_object", "module", "form", "scd"] + assert result["components"][2]["read_selector"]["method"] == "metadata.object.form.details" + assert result["components"][3]["read_selector"]["method"] == "scd.inspect" + assert result["components"][3]["read_selector"]["params"]["extension"] == "test2" + assert result["unresolved"] == [] + + +def test_components_marks_a_failed_area_instead_of_creating_a_child(monkeypatch) -> None: + object_card = {"ref": "CommonForm.t_Форма", "kind": "CommonForm", "name": "t_Форма"} + monkeypatch.setattr(adapter, "metadata_object_modules", lambda _: {"status": "ok", "object": object_card, "modules": []}) + monkeypatch.setattr(adapter, "metadata_object_forms", lambda _: {"status": "ok", "object": object_card, "forms": []}) + monkeypatch.setattr(adapter, "metadata_object_templates", lambda _: {"status": "unsupported", "error": "not_decoded", "templates": []}) + + result = adapter.metadata_object_components({"base_id": "upo_test", "ref": "CommonForm.t_Форма"}) + + assert [item["component_kind"] for item in result["components"]] == ["metadata_object"] + assert result["unresolved"] == [{"area": "templates", "method": "metadata.object.templates", "status": "unsupported", "error": "not_decoded"}] diff --git a/tests/1c/test_onec_agent_api.py b/tests/1c/test_onec_agent_api.py index f4177cf..45ccf00 100644 --- a/tests/1c/test_onec_agent_api.py +++ b/tests/1c/test_onec_agent_api.py @@ -562,7 +562,7 @@ def test_adapter_live_methods_require_base_id(onec_agent_server: dict[str, str]) assert status == 400 assert is_error assert turn_data["error"]["code"] == "adapter_error" - assert "requires params.base_id" in turn_data["error"]["message"] + assert "forbidden technical fields: table" in turn_data["error"]["message"] audit_root = Path(onec_agent_server["audit_root"]) turn_files = sorted((audit_root / "turn_audit").glob("*.jsonl")) @@ -572,10 +572,89 @@ def test_adapter_live_methods_require_base_id(onec_agent_server: dict[str, str]) record["outcome"] == "failure" and record["failure_type"] == "adapter_error" and record["error_code"] == "adapter_error" - and "requires params.base_id" in str(record["error_message"]) + and "forbidden technical fields: table" in str(record["error_message"]) for record in turn_records ) + +def test_agent_adapter_policy_uses_effective_view_and_blocks_storage_selectors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + prepared = onec_agent.prepare_agent_adapter_call( + "metadata.object.full", + {"base_id": "upo_test", "name": "Отчет.Продажи"}, + ) + assert prepared["configuration_view"] == "effective_working" + assert prepared["source_state"] == "working" + + with pytest.raises(ValueError, match="forbidden technical fields: table"): + onec_agent.prepare_agent_adapter_call( + "metadata.saved_state.forms.search", + {"base_id": "upo_test", "table": "ConfigSave"}, + ) + + with pytest.raises(ValueError, match="forbidden technical fields: module_ref"): + onec_agent.prepare_agent_adapter_call( + "code.read", + {"base_id": "upo_test", "selector": {"module_ref": "private"}}, + ) + + monkeypatch.setenv("ONEC_AGENT_ALLOW_DIAGNOSTIC", "true") + assert onec_agent.prepare_agent_adapter_call( + "metadata.saved_state.forms.search", + {"base_id": "upo_test", "table": "ConfigSave"}, + )["table"] == "ConfigSave" + + +def test_agent_calls_adapter_through_mcp(monkeypatch: pytest.MonkeyPatch) -> None: + requests: list[tuple[dict[str, Any], dict[str, str], str]] = [] + + class FakeResponse: + def __init__(self, body: dict[str, Any], headers: dict[str, str] | None = None) -> None: + self._body = json.dumps(body).encode("utf-8") + self.headers = headers or {} + + def read(self) -> bytes: + return self._body + + def __enter__(self): + return self + + def __exit__(self, *_args: object) -> None: + return None + + def fake_urlopen(request: Request, timeout: float): + body = json.loads(request.data.decode("utf-8")) + requests.append((body, dict(request.headers), request.full_url)) + if body["method"] == "initialize": + return FakeResponse({"jsonrpc": "2.0", "id": body["id"], "result": {}}, {"Mcp-Session-Id": "test-session"}) + return FakeResponse( + { + "jsonrpc": "2.0", + "id": body["id"], + "result": {"content": [{"type": "text", "text": json.dumps({"status": "ok"})}]}, + } + ) + + monkeypatch.setattr(onec_agent, "urlopen", fake_urlopen) + result = onec_agent.call_adapter("metadata.object.forms", {"base_id": "upo_test", "ref": "Report.Тест"}) + + assert result == {"status": "ok"} + assert len(requests) == 2 + assert requests[0][0]["method"] == "initialize" + assert requests[1][0]["method"] == "tools/call" + assert requests[1][0]["params"]["name"] == "onec_request" + assert requests[1][0]["params"]["arguments"]["payload"]["source_state"] == "working" + assert requests[1][2] == "http://docker.cin.su:8021/mcp" + + +def test_agent_system_prompt_requires_mcp_name_first_navigation() -> None: + prompt = onec_agent.DEFAULT_SYSTEM_PROMPT_PATH.read_text(encoding="utf-8") + assert "только инструментом MCP `onec_request`" in prompt + assert "source_state=working" in prompt + assert "Не проси и не подставляй GUID либо `module_ref`" in prompt + + def test_get_chat_runtime(onec_agent_server: dict[str, str]) -> None: url = onec_agent_server["url"] diff --git a/tests/1c/test_payload_codec.py b/tests/1c/test_payload_codec.py index 0ae4f2d..210c4bd 100644 --- a/tests/1c/test_payload_codec.py +++ b/tests/1c/test_payload_codec.py @@ -4,6 +4,7 @@ import sys import base64 import hashlib import json +import os import sqlite3 import time from pathlib import Path @@ -34,8 +35,9 @@ from parser.config_object import find_identity # noqa: E402 from parser.config_semantic import decode_config_semantic # noqa: E402 from parser.cas_payload import classify_payload, replace_stream_block, stream_header # noqa: E402 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_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, form_item_coverage_summary, section_record_semantic_properties # noqa: E402 from parser.form_xml import decode_form_xml # noqa: E402 +from parser.scd_payload import compare_scd_semantics, inspect_scd_payload, plan_scd_scalar_patch # 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 @@ -52,6 +54,149 @@ 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_template_bsl_usage_trace_resolves_static_area_parameters() -> None: + trace = adapter_server._template_bsl_usage_trace( + ''' +Макет = УправлениеПечатью.МакетПечатнойФормы("Отчет.Тест.ПФ_MXL_Тест"); +ОбластьИтог = Макет.ПолучитьОбласть("Итог"); +ОбластьИтог.Параметры.Сумма = Данные.Сумма; +СреднийЧек = ?(Данные.Количество = 0, 0, + Данные.Сумма / Данные.Количество); +ОбластьИтог.Параметры.СреднийЧек = СреднийЧек; +ОбластьИтог.Параметры.Заполнить(СтрокаДанных); +''', + "ПФ_MXL_Тест", + ) + + assert trace["status"] == "confirmed" + assert trace["template_retrievals"][0]["variable"] == "Макет" + assert trace["external_report_calls"] == [] + assert trace["areas"] == [{ + "name": "Итог", "variable": "ОбластьИтог", "line": 3, + "parameters": [ + {"name": "Сумма", "expression": "Данные.Сумма", "line": 4, "resolution": "static_assignment"}, + {"name": "СреднийЧек", "expression": "СреднийЧек", "line": 7, "resolution": "static_assignment", + "expression_lineage": {"variable": "СреднийЧек", "expression": "?(Данные.Количество = 0, 0, Данные.Сумма / Данные.Количество)", "line": 5, "resolution": "local_static_assignment"}}, + ], + "bulk_fill_sources": [{"expression": "СтрокаДанных", "line": 8, "resolution": "bulk_fill_unexpanded"}], + }] + + +def test_template_bsl_usage_trace_does_not_claim_dynamic_area_as_resolved() -> None: + trace = adapter_server._template_bsl_usage_trace( + ''' +Макет = ПолучитьМакет("ПФ_MXL_Тест"); +Область = Макет.ПолучитьОбласть(ИмяОбласти); +''', + "ПФ_MXL_Тест", + ) + + assert trace["status"] == "confirmed" + assert trace["areas"] == [] + + +def test_template_bsl_usage_trace_follows_format_wrapper_to_local_formula() -> None: + trace = adapter_server._template_bsl_usage_trace( + ''' +Макет = ПолучитьМакет("ПФ_MXL_Тест"); +Область = Макет.ПолучитьОбласть("Итог"); +СреднийЧек = Сумма / Количество; +Область.Параметры.СреднийЧек = Формат(СреднийЧек, "ЧДЦ=2"); +''', + "ПФ_MXL_Тест", + ) + + lineage = trace["areas"][0]["parameters"][0]["expression_lineage"] + assert lineage == { + "wrapper": "Формат", "resolution": "wrapped_local_static_assignment", + "input": {"variable": "СреднийЧек", "expression": "Сумма / Количество", "line": 4, "resolution": "local_static_assignment"}, + } + + +def test_template_bsl_usage_trace_follows_accumulated_structure_member() -> None: + trace = adapter_server._template_bsl_usage_trace( + ''' +Макет = ПолучитьМакет("ПФ_MXL_Тест"); +Область = Макет.ПолучитьОбласть("Итог"); +Итоги.Цена = Итоги.Цена + СтрокаДанных.Цена * СтрокаДанных.Количество; +Область.Параметры.Цена = Итоги.Цена; +''', + "ПФ_MXL_Тест", + ) + + lineage = trace["areas"][0]["parameters"][0]["expression_lineage"] + assert lineage == { + "member": "Итоги.Цена", "expression": "Итоги.Цена + СтрокаДанных.Цена * СтрокаДанных.Количество", + "line": 4, "resolution": "member_static_assignment", "aggregation": "accumulation", + "source_fields": [ + {"expression": "СтрокаДанных.Цена", "variable": "СтрокаДанных", "field": "Цена"}, + {"expression": "СтрокаДанных.Количество", "variable": "СтрокаДанных", "field": "Количество"}, + ], + } + + +def test_template_bsl_usage_trace_exposes_external_report_call_as_context_only() -> None: + trace = adapter_server._template_bsl_usage_trace( + ''' +Макет = ПолучитьМакет("ПФ_MXL_Тест"); +Дерево = Отчеты.ИсточникДанных.ТаблицаДанныхПланирования(Схема, Настройки); +''', "ПФ_MXL_Тест", + ) + + assert trace["external_report_calls"] == [{ + "report": "Report.ИсточникДанных", "method": "ТаблицаДанныхПланирования", "line": 3, + "resolution": "static_external_report_call", + }] + + +def test_template_field_lineage_returns_compact_static_parameter_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "templates_bindings", lambda _payload: { + "status": "ok", "object": {"ref": "Report.Тест"}, "bindings": [{ + "template": {"name": "ПФ_MXL_Тест"}, "code_usage": {"static_traces": [{ + "owner": "Report.Тест", "module": "Object module", "routine": "Печать", + "read_selector": {"method": "code.read", "module_ordinal": 1}, + "areas": [{"name": "Итог", "variable": "ОбластьИтог", "line": 3, "parameters": [{ + "name": "Цена", "expression": "Итоги.Цена", "line": 4, "resolution": "static_assignment", + }]}], + }]}, + }], + }) + + result = adapter_server.template_field_lineage({ + "base_id": "test", "ref": "Report.Тест", "template": "ПФ_MXL_Тест", "field": "Цена", + }) + + assert result["status"] == "confirmed" + assert result["lineage"][0]["area"]["name"] == "Итог" + assert result["lineage"][0]["parameter"]["expression"] == "Итоги.Цена" + + +def test_public_errors_hide_tracebacks_unless_debug_is_explicit(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("ONEC_ADAPTER_DEBUG_DIAGNOSTICS", raising=False) + monkeypatch.delenv("ONEC_MCP_DEBUG_DIAGNOSTICS", raising=False) + adapter_error = adapter_server.adapter_public_error( + "test.method", + "failed", + {"message": "failed", "traceback": "private adapter path"}, + ) + mcp_error = adapter_mcp.public_error( + "test.method", + "failed", + {"message": "failed", "traceback": "private MCP path"}, + ) + assert "traceback" not in adapter_error["diagnostics"] + assert "traceback" not in mcp_error["diagnostics"] + + monkeypatch.setenv("ONEC_ADAPTER_DEBUG_DIAGNOSTICS", "true") + monkeypatch.setenv("ONEC_MCP_DEBUG_DIAGNOSTICS", "true") + assert adapter_server.adapter_public_error( + "test.method", "failed", {"traceback": "adapter trace"} + )["diagnostics"]["traceback"] == "adapter trace" + assert adapter_mcp.public_error( + "test.method", "failed", {"traceback": "MCP trace"} + )["diagnostics"]["traceback"] == "MCP trace" + + def test_dbnames_version_parser_accepts_utf8_bom_scalar_version() -> None: version = "E27A62F5-8C82-4132-BD16-281F9E47DEFC" @@ -63,6 +208,360 @@ def test_dbnames_version_parser_accepts_utf8_bom_scalar_version() -> None: assert parsed["compression"] == "none" +def test_scd_payload_decoder_reads_xml_after_platform_prefix() -> None: + xml = """ + + ОсновнойНаборВЫБРАТЬ &Период КАК Период // &Скрытый КАК Скрытый + Периодxs:dateTime + Период + Сумма + СуммаСНалогомСумма * 1.2 + СуммаСумма + Сумма + Основной +""" + payload = b"\x00\x00\x00\x00\x01\x00\x00\x00" + xml.encode("utf-8") + + result = inspect_scd_payload(compress_payload(payload, "raw_deflate")) + + assert result["status"] == "ok" + assert result["container"]["compression"] == "raw_deflate" + assert result["sections"]["parameters"] == [{"name": "Период", "source": {"kind": "scd_xml", "path": "/dataCompositionSchema[1]/parameter"}, "value_type": "xs:dateTime"}] + assert result["sections"]["datasets"][0]["query"] == "ВЫБРАТЬ &Период КАК Период // &Скрытый КАК Скрытый" + assert result["sections"]["calculated_fields"][0]["expression"] == "Сумма * 1.2" + assert result["sections"]["variants"][0]["name"] == "Основной" + assert result["analysis"]["query_parameter_references"] == [{"dataset": "ОсновнойНабор", "parameters": ["Период"]}] + assert result["analysis"]["referenced_not_declared_in_schema"] == [] + assert result["analysis"]["total_field_references"] == {"fields": ["Сумма"], "missing_from_declared_fields": [], "status": "checked"} + assert result["analysis"]["settings_context"] == {"status": "not_present", "sections": {}} + + +def test_scd_scalar_patch_preserves_platform_prefix_and_trailer() -> None: + xml = """ + +ОсновнойНаборВЫБРАТЬ 1 КАК Значение +""" + plain = b"\x00\x00\x00\x00\x01\x00\x00\x00" + xml.encode("utf-8") + b"\x11\x22\x33\x44" + source = compress_payload(plain, "raw_deflate") + + plan = plan_scd_scalar_patch( + source, + section="datasets", + name="ОсновнойНабор", + property_name="query", + value="ВЫБРАТЬ &Период КАК Период", + ) + + assert plan["status"] == "planned" + patched = decode_payload_lossless(plan["payload"])["payload"] + assert patched.startswith(b"\x00\x00\x00\x00\x01\x00\x00\x00") + assert patched.endswith(b"\x11\x22\x33\x44") + decoded = inspect_scd_payload(plan["payload"]) + assert decoded["sections"]["datasets"][0]["query"] == "ВЫБРАТЬ &Период КАК Период" + assert decoded["analysis"]["query_parameter_references"] == [{"dataset": "ОсновнойНабор", "parameters": ["Период"]}] + + +def test_scd_patch_apply_and_rollback_uses_apply_backup(monkeypatch: pytest.MonkeyPatch) -> None: + """A SCD probe must restore raw storage bytes, not re-encode old XML text.""" + source = b"original" + replacement = b"replacement" + monkeypatch.setattr(adapter_server, "scd_inspect", lambda _payload: { + "status": "ok", + "scd": {"guid": "test-guid", "name": "ОсновнаяСхема", "payload_route": {"file_name": "test-guid.0"}}, + }) + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", lambda *_args, **_kwargs: (source, {}, None)) + from parser import scd_payload + monkeypatch.setattr(scd_payload, "plan_scd_scalar_patch", lambda *_args, **_kwargs: { + "status": "planned", "payload": replacement, "expected_sha1": "a" * 40, + "result_sha1": "b" * 40, "old": "ВЫБРАТЬ 1", "new": "ВЫБРАТЬ 2", + }) + monkeypatch.setattr(scd_payload, "inspect_scd_payload", lambda _data: { + "sections": {"datasets": [{"name": "Набор", "query": "ВЫБРАТЬ 2"}]}, "analysis": {}, + }) + monkeypatch.setattr(adapter_server, "storage_saved_state_apply_proposal", lambda _payload: { + "schema": "onec_storage_saved_state_apply.v1", "status": "applied", "applied": True, + "backup": {"backup_id": "c" * 32}, + }) + rollback_calls: list[dict[str, object]] = [] + monkeypatch.setattr(adapter_server, "storage_saved_state_rollback", lambda payload: rollback_calls.append(payload) or {"status": "applied", "applied": True}) + + result = adapter_server.scd_patch({ + "base_id": "upo_test", "report": "Тест", "section": "datasets", "item_name": "Набор", + "property": "query", "value": "ВЫБРАТЬ 2", "allow_saved_state_write": True, + "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["rolled_back"] is True + assert rollback_calls == [{ + "base_id": "upo_test", "allow_sql_saved_state_rollback": True, + "backup_id": "c" * 32, "timeout_seconds": 60, + }] + + +def test_scd_patch_rollback_gate_precedes_sql_apply(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "scd_inspect", lambda _payload: { + "status": "ok", + "scd": {"guid": "test-guid", "name": "ОсновнаяСхема", "payload_route": {"file_name": "test-guid.0"}}, + }) + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", lambda *_args, **_kwargs: (b"original", {}, None)) + from parser import scd_payload + monkeypatch.setattr(scd_payload, "plan_scd_scalar_patch", lambda *_args, **_kwargs: { + "status": "planned", "payload": b"replacement", "expected_sha1": "a" * 40, + "result_sha1": "b" * 40, "old": "ВЫБРАТЬ 1", "new": "ВЫБРАТЬ 2", + }) + apply_calls: list[dict[str, object]] = [] + monkeypatch.setattr(adapter_server, "storage_saved_state_apply_proposal", lambda payload: apply_calls.append(payload) or {}) + + result = adapter_server.scd_patch({ + "base_id": "upo_test", "report": "Тест", "section": "datasets", "item_name": "Набор", + "property": "query", "value": "ВЫБРАТЬ 2", "allow_saved_state_write": True, + "execution_mode": "apply_and_rollback", "allow_sql_saved_state_apply": True, + }) + + assert result["status"] == "invalid_argument" + assert "allow_sql_saved_state_rollback" in str(result) + assert apply_calls == [] + + +def test_scd_patch_auto_prepares_missing_saved_payload(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "scd_inspect", lambda _payload: { + "status": "ok", + "scd": {"guid": "test-guid", "name": "ОсновнаяСхема", "payload_route": {"file_name": "test-guid.0"}}, + }) + reads = iter([(None, {}, {"status": "not_found"}), (b"saved", {}, None), (b"saved", {}, None)]) + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", lambda *_args, **_kwargs: next(reads)) + monkeypatch.setattr(adapter_server, "scd_prepare", lambda _payload: { + "status": "applied", "prepare_receipt_id": "d" * 32, + }) + from parser import scd_payload + monkeypatch.setattr(scd_payload, "plan_scd_scalar_patch", lambda *_args, **_kwargs: { + "status": "planned", "payload": b"replacement", "expected_sha1": "a" * 40, + "result_sha1": "b" * 40, "old": "ВЫБРАТЬ 1", "new": "ВЫБРАТЬ 2", + }) + monkeypatch.setattr(scd_payload, "inspect_scd_payload", lambda _data: { + "sections": {"datasets": [{"name": "Набор", "query": "ВЫБРАТЬ 2"}]}, "analysis": {}, + }) + monkeypatch.setattr(adapter_server, "storage_saved_state_apply_proposal", lambda _payload: { + "status": "applied", "applied": True, "backup": {"backup_id": "c" * 32}, + }) + + result = adapter_server.scd_patch({ + "base_id": "upo_test", "report": "Тест", "section": "datasets", "item_name": "Набор", + "property": "query", "value": "ВЫБРАТЬ 2", "allow_saved_state_write": True, + "execution_mode": "apply_and_verify", "allow_sql_saved_state_apply": True, + "allow_sql_saved_state_prepare": True, + }) + + assert result["status"] == "verified" + assert result["prepare_receipt_id"] == "d" * 32 + assert result["prepare_result"]["status"] == "applied" + + +def test_scd_patch_auto_prepare_rolls_back_patch_and_overlay(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "scd_inspect", lambda _payload: { + "status": "ok", + "scd": {"guid": "test-guid", "name": "ОсновнаяСхема", "payload_route": {"file_name": "test-guid.0"}}, + }) + reads = iter([(None, {}, {"status": "not_found"}), (b"saved", {}, None), (b"saved", {}, None)]) + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", lambda *_args, **_kwargs: next(reads)) + monkeypatch.setattr(adapter_server, "scd_prepare", lambda _payload: { + "status": "applied", "prepare_receipt_id": "d" * 32, + }) + from parser import scd_payload + monkeypatch.setattr(scd_payload, "plan_scd_scalar_patch", lambda *_args, **_kwargs: { + "status": "planned", "payload": b"replacement", "expected_sha1": "a" * 40, + "result_sha1": "b" * 40, "old": "ВЫБРАТЬ 1", "new": "ВЫБРАТЬ 2", + }) + monkeypatch.setattr(scd_payload, "inspect_scd_payload", lambda _data: { + "sections": {"datasets": [{"name": "Набор", "query": "ВЫБРАТЬ 2"}]}, "analysis": {}, + }) + monkeypatch.setattr(adapter_server, "storage_saved_state_apply_proposal", lambda _payload: { + "status": "applied", "applied": True, "backup": {"backup_id": "c" * 32}, + }) + monkeypatch.setattr(adapter_server, "storage_saved_state_rollback", lambda _payload: {"status": "applied", "applied": True}) + cleanup_calls: list[str] = [] + monkeypatch.setattr(adapter_server, "rollback_saved_state_prepare_receipt", lambda _base, receipt, **_kwargs: cleanup_calls.append(receipt) or {"status": "rolled_back", "applied": True}) + + result = adapter_server.scd_patch({ + "base_id": "upo_test", "report": "Тест", "section": "datasets", "item_name": "Набор", + "property": "query", "value": "ВЫБРАТЬ 2", "allow_saved_state_write": True, + "execution_mode": "apply_and_rollback", "allow_sql_saved_state_apply": True, + "allow_sql_saved_state_prepare": True, "allow_sql_saved_state_rollback": True, + }) + + assert result["status"] == "verified_and_rolled_back" + assert result["prepare_cleanup"]["status"] == "rolled_back" + assert cleanup_calls == ["d" * 32] + + +def test_scd_semantic_compare_uses_names_and_ignores_storage_evidence() -> None: + active = {"sections": {"datasets": [{"name": "Набор", "query": "ВЫБРАТЬ 1", "source": {"path": "/old"}}], "parameters": [{"name": "Период"}]}} + saved = {"sections": {"datasets": [{"name": "Набор", "query": "ВЫБРАТЬ 2", "source": {"path": "/new"}}], "parameters": [{"name": "Период"}, {"name": "Организация"}]}} + + result = compare_scd_semantics(active, saved) + + assert result["status"] == "changed" + assert result["sections"]["datasets"]["changed"] == ["Набор"] + assert result["sections"]["parameters"]["added"] == ["Организация"] + assert result["counts"] == {"added": 1, "removed": 0, "changed": 1, "unchanged": 1} + + +def test_dcs_field_lineage_resolves_alias_and_virtual_register_source(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "scd_inspect", lambda payload: { + "status": "ok", "base_id": "upo_test", "report": "Тест", + "sections": {"datasets": [{"name": "Набор", "query": """ +ВЫБРАТЬ + Цены.Цена КАК ЦенаПродажи +ИЗ РегистрСведений.ЦеныНоменклатуры.СрезПоследних(&Дата) КАК Цены +ГДЕ Цены.ВидЦен = &ВидЦен +"""}], "fields": [], "calculated_fields": [], "resources": []}, + }) + + result = adapter_server.dcs_field_lineage({"base_id": "upo_test", "report": "Тест", "field": "ЦенаПродажи"}) + reference = result["lineage"][0]["direct_references"][0] + + assert reference["public_path"] == "РегистрСведений.ЦеныНоменклатуры.СрезПоследних.Цена" + assert reference["virtual_source"] == { + "register": "РегистрСведений.ЦеныНоменклатуры", + "virtual_table": "СрезПоследних", + "status": "static_source_confirmed", + "arguments": "&Дата", + "filter": "Цены.ВидЦен = &ВидЦен", + } + assert result["agent_summary"]["status"] == "confirmed" + assert result["agent_summary"]["confirmed_sources"][0]["source"] == reference["public_path"] + + +def test_scd_inspect_reports_unproven_name_instead_of_claiming_template_match(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_object_related", + lambda payload: { + "status": "ok", + "related": [{"category": "DataCompositionSchema", "status": "ok", "name": None, "guid": "11111111-1111-1111-1111-111111111111"}], + }, + ) + + result = adapter_server.scd_inspect( + {"base_id": "upo_test", "report": "Продажи", "schema": "ОсновнаяСхемаКомпоновкиДанных"} + ) + + assert result["status"] == "partial" + assert result["error"] == "scd_schema_name_not_proven" + assert result["requested_schema"] == "ОсновнаяСхемаКомпоновкиДанных" + assert result["diagnostics"]["next_call"]["params"] == {"base_id": "upo_test", "report": "Продажи"} + + +def test_dcs_field_lineage_follows_simple_temporary_table_projection(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "scd_inspect", lambda payload: { + "status": "ok", "base_id": "upo_test", "report": "Тест", + "sections": {"datasets": [{"name": "Набор", "query": """ +ВЫБРАТЬ + Цены.Цена КАК ЦенаПродажи +ПОМЕСТИТЬ втЦены +ИЗ РегистрСведений.ЦеныНоменклатуры КАК Цены; + +ВЫБРАТЬ + втЦены.ЦенаПродажи КАК ЦенаПродажи +ИЗ втЦены КАК втЦены +"""}], "fields": [], "calculated_fields": [], "resources": []}, + }) + + result = adapter_server.dcs_field_lineage({"base_id": "upo_test", "report": "Тест", "field": "ЦенаПродажи"}) + references = [reference for item in result["lineage"] for reference in item.get("direct_references", [])] + temporary_reference = next(reference for reference in references if reference.get("resolution") == "temporary_table_projection") + + assert temporary_reference["public_path"] == "РегистрСведений.ЦеныНоменклатуры.Цена" + + +def test_dcs_field_lineage_follows_temporary_table_expression(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "scd_inspect", lambda payload: { + "status": "ok", "base_id": "upo_test", "report": "Тест", + "sections": {"datasets": [{"name": "Набор", "query": """ +ВЫБРАТЬ + ЕСТЬNULL(Цены.Цена, 0) КАК ЦенаПродажи +ПОМЕСТИТЬ втЦены +ИЗ РегистрСведений.ЦеныНоменклатуры КАК Цены; + +ВЫБРАТЬ + втЦены.ЦенаПродажи КАК ЦенаПродажи +ИЗ втЦены КАК втЦены +"""}], "fields": [], "calculated_fields": [], "resources": []}, + }) + + result = adapter_server.dcs_field_lineage({"base_id": "upo_test", "report": "Тест", "field": "ЦенаПродажи"}) + sources = result["agent_summary"]["confirmed_sources"] + assert any(item["source"] == "РегистрСведений.ЦеныНоменклатуры.Цена" for item in sources) + assert not result["agent_summary"]["partial_steps"] + assert any(item.get("resolution") == "temporary_table_expression" for item in result["agent_summary"]["confirmed_sources"]) + + +def test_dcs_field_lineage_reports_calculated_field_dependencies(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "scd_inspect", lambda payload: { + "status": "ok", "base_id": "upo_test", "report": "Тест", + "sections": { + "datasets": [], "fields": [{"data_path": "Сумма"}, {"data_path": "Ставка"}], + "calculated_fields": [{"name": "СуммаСНалогом", "expression": "Сумма * (1 + Ставка)"}], "resources": [], + }, + }) + + result = adapter_server.dcs_field_lineage({"base_id": "upo_test", "report": "Тест", "field": "СуммаСНалогом"}) + + assert result["lineage"][0]["field_dependencies"] == ["Ставка", "Сумма"] + assert result["agent_summary"]["partial_steps"][0]["reason"] == "calculated_expression_inputs_not_resolved" + + +def test_dcs_field_lineage_marks_union_branches(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "scd_inspect", lambda payload: { + "status": "ok", "base_id": "upo_test", "report": "Тест", + "sections": {"datasets": [{"name": "Набор", "query": """ +ВЫБРАТЬ А.Цена КАК Цена +ИЗ РегистрСведений.ЦеныОпт КАК А +ОБЪЕДИНИТЬ ВСЕ +ВЫБРАТЬ Б.Цена КАК Цена +ИЗ РегистрСведений.ЦеныРозница КАК Б +"""}], "fields": [], "calculated_fields": [], "resources": []}, + }) + + result = adapter_server.dcs_field_lineage({"base_id": "upo_test", "report": "Тест", "field": "Цена"}) + + assert {item["branch"] for item in result["lineage"] if item.get("branch")} == {1, 2} + assert {item["branches"][0] for item in result["agent_summary"]["confirmed_sources"]} == {1, 2} + + +def test_dcs_field_lineage_exposes_join_condition_for_source(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "scd_inspect", lambda payload: { + "status": "ok", "base_id": "upo_test", "report": "Тест", + "sections": {"datasets": [{"name": "Набор", "query": """ +ВЫБРАТЬ Цены.Цена КАК Цена +ИЗ Справочник.Номенклатура КАК Номенклатура +ЛЕВОЕ СОЕДИНЕНИЕ РегистрСведений.ЦеныНоменклатуры КАК Цены +ПО Цены.Номенклатура = Номенклатура.Ссылка +"""}], "fields": [], "calculated_fields": [], "resources": []}, + }) + + result = adapter_server.dcs_field_lineage({"base_id": "upo_test", "report": "Тест", "field": "Цена"}) + + assert result["agent_summary"]["confirmed_sources"][0]["joins"] == [{"kind": "левое", "condition": "Цены.Номенклатура = Номенклатура.Ссылка"}] + + +def test_scd_agent_report_embeds_requested_field_lineage(monkeypatch: pytest.MonkeyPatch) -> None: + inspected = { + "status": "ok", "base_id": "upo_test", "report": "Тест", "extension": None, + "scd": {"name": "ОсновнаяСхемаКомпоновкиДанных"}, + "sections": {"datasets": [], "fields": [], "calculated_fields": [], "resources": [], "parameters": []}, + "analysis": {"data_source_resolution": {"sources": []}, "direct_field_resolution": {"references": []}, "query_parameter_references": [], "referenced_not_declared_in_schema": [], "total_field_references": {}}, + } + monkeypatch.setattr(adapter_server, "scd_inspect", lambda payload: inspected) + monkeypatch.setattr(adapter_server, "dcs_field_lineage", lambda payload: {"status": "ok", "field": payload["field"], "agent_summary": {"status": "confirmed"}}) + + result = adapter_server.scd_agent_report({"base_id": "upo_test", "report": "Тест", "field": "Цена"}) + + assert result["field_lineage"]["field"] == "Цена" + + @pytest.mark.parametrize( ("payload", "message"), [ @@ -231,13 +730,15 @@ def test_runtime_method_registry_is_unique_and_covers_contract_metadata() -> Non 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 diagnostics["counts"]["specialized_http_routes"] == 80 + assert diagnostics["counts"]["methods_with_input_schema"] == 185 + assert len(registry) == len(adapter_server.METHODS) == 185 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 registry["metadata.relationship.verify"]["input_schema"]["required"] == ["base_id", "member"] + assert registry["metadata.relationship.find"]["input_schema"]["properties"]["direction"]["enum"] == ["outgoing", "incoming", "either"] + assert response["count"] == 185 assert response["registry"] == diagnostics @@ -420,6 +921,175 @@ def test_write_plan_path_accepts_code_roots_and_rejects_unknown_roots() -> None: assert unknown["reason"] == "unknown_object_kind" +def test_code_write_accepts_exact_extension_report_form_module_path() -> None: + resolved = adapter_server.code_write_extension_form_module_path( + "Расширение.фс_Отчеты1.Отчеты.фс_ПродовольственнаяКорзина.Формы.ФормаОтчетаПродкорзина.МодульФормы" + ) + + assert resolved == { + "extension": "фс_Отчеты1", + "object_type": "Report", + "object_name": "фс_ПродовольственнаяКорзина", + "form": "ФормаОтчетаПродкорзина", + "canonical_path": "Отчет.фс_ПродовольственнаяКорзина.Формы.ФормаОтчетаПродкорзина.МодульФормы", + } + assert adapter_server.metadata_write_plan_path_parts(resolved["canonical_path"])["path_kind"] == "form_module" + + +def test_metadata_write_target_resolve_keeps_logical_target_separate_from_module_container(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "extension_filter_to_guid", lambda *_args, **_kwargs: ("11111111-1111-1111-1111-111111111111", None)) + monkeypatch.setattr( + adapter_server, + "saved_state_extension_form_active_route", + lambda **_kwargs: ({ + "file_name": "active-form-payload", + "owner": {"kind": "Report", "name": "фс_ПродовольственнаяКорзина"}, + "form": {"name": "ФормаОтчетаПродкорзина", "guid": "22222222-2222-2222-2222-222222222222"}, + "source": {"table": "ConfigCAS", "file_name": "active-form-payload"}, + }, None), + ) + + resolved = adapter_server.metadata_write_target_resolve({ + "base_id": "upo_test", + "path": "Расширение.фс_Отчеты1.Отчеты.фс_ПродовольственнаяКорзина.Формы.ФормаОтчетаПродкорзина.МодульФормы", + }) + + assert resolved["status"] == "resolved" + assert resolved["write_target"]["expected_origin"]["extension"] == "фс_Отчеты1" + assert resolved["working_state"]["table"] == "ConfigCASSave" + assert resolved["module_route"]["status"] == "unresolved" + assert "module_ref" not in resolved["write_target"] + + +def test_metadata_write_target_resolve_rejects_old_fragment_from_unbound_container(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "extension_filter_to_guid", lambda *_args, **_kwargs: ("11111111-1111-1111-1111-111111111111", None)) + monkeypatch.setattr( + adapter_server, + "saved_state_extension_form_active_route", + lambda **_kwargs: ({ + "source": {"table": "ConfigCAS", "file_name": "active-form-payload"}, + "owner": {"kind": "Report", "name": "фс_ПродовольственнаяКорзина"}, + "form": {"name": "ФормаОтчетаПродкорзина"}, + }, None), + ) + monkeypatch.setattr(adapter_server, "read_module", lambda _payload: {"status": "ok", "text": "Процедура Тест()\nКонецПроцедуры", "content_sha1": "b" * 40}) + + result = adapter_server.metadata_write_target_resolve({ + "base_id": "upo_test", + "path": "Расширение.фс_Отчеты1.Отчеты.фс_ПродовольственнаяКорзина.Формы.ФормаОтчетаПродкорзина.МодульФормы", + "old": "Вертикальная", + }) + + assert result["replacement_precondition"]["status"] == "old_not_found_in_confirmed_target" + assert result["replacement_precondition"]["occurrences"] == 0 + + +def test_metadata_object_forms_keeps_explicit_config_table_and_does_not_auto_switch_to_extension(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "fast_extension_object_exact_match", + lambda *_args, **_kwargs: pytest.fail("An explicit Config route must not auto-switch to ConfigCAS"), + ) + monkeypatch.setattr(adapter_server, "get_object", lambda *_args, **_kwargs: {"status": "not_found"}) + + result = adapter_server.metadata_object_forms({ + "base_id": "upo_test", + "kind": "Report", + "name": "Тест", + "table": "Config", + }) + + assert result["status"] == "not_found" + + +def test_get_object_explicit_config_never_falls_back_to_extension_definition(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "live_dbnames_records", lambda *_args, **_kwargs: ([], None)) + monkeypatch.setattr(adapter_server, "live_base_root_metadata_index", lambda *_args, **_kwargs: ([], [])) + monkeypatch.setattr( + adapter_server, + "metadata_extension_definition_matches", + lambda *_args, **_kwargs: pytest.fail("Config lookup must not scan extension definitions"), + ) + + result = adapter_server.get_object("Report", "ТолькоВРасширении", base_id="upo_test", table="Config", timeout_seconds=5) + + assert result["status"] == "not_found" + + +def test_module_text_response_exposes_stable_text_preconditions() -> None: + result = adapter_server.module_text_response("Процедура Тест()\nКонецПроцедуры", {"include_text": True}) + + assert result["content_sha1"] + assert result["selection_sha1"] == result["content_sha1"] + + +def test_code_write_rejects_mismatched_extension_path_and_origin() -> None: + result = adapter_server.code_write({ + "base_id": "upo_test", + "execution_mode": "apply", + "old": "Вертикальная", + "new": "Горизонтальная", + "write_target": { + "path": "Расширение.фс_Отчеты1.Отчеты.фс_ПродовольственнаяКорзина.Формы.ФормаОтчетаПродкорзина.МодульФормы", + "expected_origin": {"extension": "другоеРасширение"}, + "expected_sha1": "a" * 40, + }, + }) + + assert result["status"] == "invalid_argument" + assert result["argument"] == "write_target.expected_origin.extension" + + +def test_code_search_excludes_unbound_hit_when_extension_form_scope_is_exact(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "code_saved_state_common_form_module_search", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + adapter_server, + "search_modules", + lambda _payload: { + "status": "ok", + "matches": [{ + "owner": {"kind": "Report", "name": "фс_ПродовольственнаяКорзина"}, + "module": {"form": {"name": "ФормаОтчетаПродкорзина"}}, + "origin": {"extension": {"name": "другоеРасширение"}}, + "snippet": {"text": "Вертикальная", "offset": 0}, + }], + "counts": {"complete": True}, + }, + ) + + result = adapter_server.code_search({ + "base_id": "upo_test", + "extension": "фс_Отчеты1", + "kind": "Report", + "name": "фс_ПродовольственнаяКорзина", + "form": "ФормаОтчетаПродкорзина", + "query": "Вертикальная", + }) + + assert result["status"] == "not_found" + assert result["counts"]["excluded_unbound_matches"] == 1 + + +def test_form_details_uses_configcas_for_extension_when_table_is_not_explicit(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_forms(payload: dict[str, Any]) -> dict[str, Any]: + seen["table"] = payload.get("table") + return {"status": "not_found"} + + monkeypatch.setattr(adapter_server, "metadata_object_forms", fake_forms) + result = adapter_server.metadata_object_form_details({ + "base_id": "upo_test", + "extension": "test2", + "kind": "Report", + "name": "Тест", + "form": "Форма", + }) + + assert result["status"] == "not_found" + assert seen["table"] == "ConfigCAS" + + 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" @@ -1127,6 +1797,11 @@ def test_service_module_roles_and_full_stream_selection(monkeypatch: pytest.Monk }, ) monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (None, None)) + monkeypatch.setattr( + adapter_server, + "metadata_object_forms", + lambda payload: {"status": "ok", "forms": []}, + ) monkeypatch.setattr( adapter_server, "metadata_object_parts", @@ -2106,6 +2781,302 @@ def test_module_owner_cache_prune_rejects_unresolved_name(monkeypatch: pytest.Mo } +def test_module_owner_cache_upsert_enriches_active_and_saved_code_index_rows( + 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"} + active_ref = "Config:common-module.0#stream:4" + saved_ref = "ConfigSave:common-module.0#stream:4" + text = "Процедура Проверка()\nКонецПроцедуры" + for table, module_ref in (("Config", active_ref), ("ConfigSave", saved_ref)): + adapter_server.code_index_upsert( + config, + base_id="upo_test", + table=table, + file_name="common-module.0", + module_ref=module_ref, + data=text.encode("utf-8"), + text=text, + ) + + enriched = adapter_server.metadata_module_owner_cache_upsert( + config, + active_ref, + { + "kind": "CommonModule", + "name": "ОбщегоНазначения", + "guid": "11111111-1111-1111-1111-111111111111", + }, + ) + + assert enriched == 2 + assert adapter_server.metadata_module_owner_cache_lookup(config, saved_ref)["owner"]["name"] == "ОбщегоНазначения" + with adapter_server.cache_connection() as conn: + rows = conn.execute( + "SELECT owner_kind, owner_name, owner_guid FROM metadata_code_index_cache ORDER BY module_ref" + ).fetchall() + assert len(rows) == 2 + assert {row["owner_name"] for row in rows} == {"ОбщегоНазначения"} + + +def test_module_owner_cache_backfill_returns_resumable_name_first_cursor( + 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" + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + monkeypatch.setattr( + adapter_server, + "list_objects", + lambda *args, **kwargs: { + "status": "ok", + "objects": [{"kind": "CommonModule", "name": "ОбщегоНазначения", "guid": owner_guid}], + "counts": {"total": 2, "total_visible": 2}, + }, + ) + + def fake_modules(payload: dict[str, Any]) -> dict[str, Any]: + module_ref = "Config:common-module.0#stream:4" + adapter_server.metadata_module_owner_cache_upsert( + config, + module_ref, + {"kind": "CommonModule", "name": "ОбщегоНазначения", "guid": owner_guid}, + ) + return { + "status": "ok", + "object": {"kind": "CommonModule", "name": "ОбщегоНазначения", "guid": owner_guid}, + "modules": [{"module_id": module_ref}], + } + + monkeypatch.setattr(adapter_server, "metadata_object_modules", fake_modules) + + result = adapter_server.metadata_module_owner_cache_backfill( + {"base_id": "upo_test", "kind": "CommonModule", "limit": 1, "offset": 0, "deep": True} + ) + + assert result["status"] == "ok" + assert result["complete"] is False + assert result["next_cursor"] == { + "kind_index": adapter_server.metadata_module_owner_backfill_kinds().index("CommonModule"), + "kind": "CommonModule", + "offset": 1, + } + assert result["objects"] == [ + { + "ref": "CommonModule.ОбщегоНазначения", + "modules": 1, + "resolved_by": "metadata.object.modules", + } + ] + assert result["counts"]["cache_entries_added"] == 1 + + +def test_module_owner_resolve_from_ref_verifies_embedded_guid( + 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 = "05a138aa-b3c0-48c6-94ea-0acd4fc000cf" + module_ref = f"Config:{owner_guid}.0#stream:4" + calls: list[tuple[str | None, str]] = [] + + def fake_get_object(kind: str | None, name: str, **kwargs: Any) -> dict[str, Any]: + calls.append((kind, name)) + return { + "status": "ok", + "object": { + "kind": "CommonModule", + "name": "ИнтеграцияС1СДокументооборотКлиентСервер", + "synonym": "Интеграция с 1С:Документооборот", + "guid": owner_guid, + }, + } + + monkeypatch.setattr(adapter_server, "get_object", fake_get_object) + + resolved = adapter_server.metadata_module_owner_resolve_from_ref( + "upo_test", + config, + module_ref, + ) + + assert calls == [(None, owner_guid)] + assert resolved["owner"]["kind"] == "CommonModule" + assert resolved["owner"]["name"] == "ИнтеграцияС1СДокументооборотКлиентСервер" + assert adapter_server.metadata_module_owner_cache_lookup(config, module_ref)["owner"]["guid"] == owner_guid + + +def test_extension_module_owner_resolve_uses_current_manifest_and_enriches_code_index( + 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"} + extension_guid = "7ba4e0fe-98c8-11ed-9fb6-0050569db20c" + owner_guid = "11111111-1111-1111-1111-111111111111" + descriptor_key = "a" * 40 + module_key = "b" * 40 + module_ref = f"ConfigCAS:{module_key}#stream:3" + text = "Функция ИзРасширения()\nКонецФункции" + adapter_server.code_index_upsert( + config, + base_id="upo_test", + table="ConfigCAS", + file_name=module_key, + module_ref=module_ref, + data=text.encode("utf-8"), + text=text, + ) + related_entries = [ + { + "object_base_id": owner_guid, + "suffix": "", + "cas_key": descriptor_key, + "extension": {"guid": extension_guid, "name": "EF_UPO_SODECSO"}, + "root_cas_key": "c" * 40, + }, + { + "object_base_id": owner_guid, + "suffix": ".0", + "cas_key": module_key, + "extension": {"guid": extension_guid, "name": "EF_UPO_SODECSO"}, + "root_cas_key": "c" * 40, + }, + ] + monkeypatch.setattr( + adapter_server, + "manifest_related_entries_for_cas_key", + lambda *args, **kwargs: (related_entries, []), + ) + monkeypatch.setattr( + adapter_server, + "read_storage_file_bytes", + lambda *args, **kwargs: (b"descriptor", config, None), + ) + monkeypatch.setattr( + adapter_server, + "config_identity_from_bytes", + lambda data: { + "guid": owner_guid, + "name": "РасширенныйОбщийМодуль", + "synonyms": {"ru": "Расширенный общий модуль"}, + }, + ) + monkeypatch.setattr( + adapter_server, + "extension_metadata_payload_kind", + lambda *args, **kwargs: "CommonModule", + ) + + resolved = adapter_server.metadata_module_owner_resolve_from_ref( + "upo_test", + config, + module_ref, + ) + + assert resolved["owner"]["kind"] == "CommonModule" + assert resolved["owner"]["name"] == "РасширенныйОбщийМодуль" + assert resolved["module_payload"]["extension"] == { + "guid": extension_guid, + "name": "EF_UPO_SODECSO", + } + with adapter_server.cache_connection() as conn: + code_row = conn.execute( + "SELECT owner_name, extension_guid, extension_name FROM metadata_code_index_cache WHERE module_ref=?", + (module_ref,), + ).fetchone() + route_row = conn.execute( + "SELECT manifest_entries_json FROM extension_route_cache WHERE descriptor_cas_key=?", + (descriptor_key,), + ).fetchone() + assert dict(code_row) == { + "owner_name": "РасширенныйОбщийМодуль", + "extension_guid": extension_guid, + "extension_name": "EF_UPO_SODECSO", + } + assert module_key in route_row["manifest_entries_json"] + + +def test_extension_metadata_payload_kind_recognizes_common_module_descriptor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + atom = lambda value: {"type": "atom", "value": value} + monkeypatch.setattr( + adapter_server, + "parse_config_tree_from_bytes", + lambda data: { + "type": "list", + "items": [ + atom("1"), + {"type": "list", "items": [atom("1"), {"type": "list", "items": []}]}, + atom("0"), + ], + }, + ) + + kind = adapter_server.extension_metadata_payload_kind(b"descriptor", {}, {}) + + assert kind == "CommonModule" + + +def test_module_owner_cache_backfill_prefers_existing_code_index( + 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 = "05a138aa-b3c0-48c6-94ea-0acd4fc000cf" + module_ref = f"Config:{owner_guid}.0#stream:4" + text = "Функция ЭтоДокумент()\nКонецФункции" + adapter_server.code_index_upsert( + config, + base_id="upo_test", + table="Config", + file_name=f"{owner_guid}.0", + module_ref=module_ref, + data=text.encode("utf-8"), + text=text, + ) + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + monkeypatch.setattr( + adapter_server, + "list_objects", + lambda *args, **kwargs: { + "status": "ok", + "objects": [ + { + "kind": "CommonModule", + "name": "ИнтеграцияС1СДокументооборотКлиентСервер", + "guid": owner_guid, + } + ], + "counts": {"total": 1, "total_visible": 1}, + }, + ) + monkeypatch.setattr( + adapter_server, + "metadata_object_modules", + lambda payload: pytest.fail("existing code-index rows must not trigger metadata object decoding"), + ) + + result = adapter_server.metadata_module_owner_cache_backfill( + {"base_id": "upo_test", "kind": "CommonModule", "limit": 1} + ) + + assert result["status"] == "complete" + assert result["counts"]["objects_resolved_from_code_index"] == 1 + assert result["counts"]["objects_resolved_from_metadata"] == 0 + assert result["objects"][0]["resolved_by"] == "code_index_guid_prefix" + assert adapter_server.metadata_module_owner_cache_lookup(config, module_ref)["owner"]["name"] == ( + "ИнтеграцияС1СДокументооборотКлиентСервер" + ) + + 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"} @@ -2160,6 +3131,33 @@ def test_code_index_verify_reports_cache_hit_stale(tmp_path: Path, monkeypatch: assert result["freshness"]["current_payload_sha1"] != result["freshness"]["payload_sha1"] +def test_code_index_search_filters_candidates_by_public_owner_ref(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"} + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + for file_name, owner_name in (("writeoff", "СписаниеЗапасов"), ("planning", "УОП_ПланированиеМеню")): + adapter_server.code_index_upsert( + config, + base_id="upo_test", + table="Config", + file_name=file_name, + module_ref=f"Config:{file_name}", + data=file_name.encode(), + text="Процедура Связь() // УОП_ПланированиеМеню\nКонецПроцедуры", + owner={"owner_kind": "Document", "owner_name": owner_name}, + verified=True, + ) + + result = adapter_server.metadata_code_index_search( + {"base_id": "upo_test", "ref": "Document.СписаниеЗапасов", "query": "УОП_ПланированиеМеню", "verify": False, "strict": False} + ) + + assert result["status"] == "ok" + assert result["query"]["ref"] == "Document.СписаниеЗапасов" + assert [item["object_ref"] for item in result["matches"]] == ["Document.СписаниеЗапасов"] + assert result["counts"]["candidates"] == 1 + + def test_code_vector_search_revalidates_candidates(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"} @@ -2189,6 +3187,439 @@ def test_code_vector_search_revalidates_candidates(tmp_path: Path, monkeypatch: assert result["source"]["authoritative"] is False assert result["matches"][0]["freshness"]["status"] == "cache_hit_verified" assert result["matches"][0]["owner"]["name"] == "t_Форма" + assert result["matches"][0]["object_ref"] == "CommonForm.t_Форма" + assert result["matches"][0]["object_selector"]["name"] == "t_Форма" + assert result["freshness"]["snapshot_token"] + + +def test_code_vector_external_embedding_pending_upsert_and_search(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"} + text = "Процедура РассчитатьНалог()\nКонецПроцедуры" + data = b"payload" + module_ref = "ConfigCAS:tax-module" + embedding_model = "openai-compatible:test-code-model" + + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", lambda *args, **kwargs: (data, config, None)) + monkeypatch.setattr( + adapter_server, + "current_code_index_text_from_sql", + lambda **kwargs: (text, {"status": "ok"}), + ) + indexed = adapter_server.code_index_upsert( + config, + base_id="upo_test", + table="ConfigCAS", + file_name="tax-module", + module_ref=module_ref, + data=data, + text=text, + owner={"owner_kind": "CommonModule", "owner_name": "Налоги"}, + verified=True, + ) + adapter_server.code_vector_upsert_chunks(config, {**indexed, "module_ref": module_ref, "text": text}) + + pending = adapter_server.metadata_code_vector_pending( + { + "base_id": "upo_test", + "embedding_model": embedding_model, + "limit": 10, + "chunk_kinds": ["routine"], + "max_text_chars": 1000, + } + ) + chunk = pending["chunks"][0] + stored = adapter_server.metadata_code_vector_embedding_upsert( + { + "base_id": "upo_test", + "chunk_id": chunk["chunk_id"], + "text_sha1": chunk["text_sha1"], + "embedding_model": embedding_model, + "embedding": [1.0, 0.0, 0.0], + } + ) + result = adapter_server.metadata_code_vector_search( + { + "base_id": "upo_test", + "query": "где считается налог", + "query_embedding": [1.0, 0.0, 0.0], + "embedding_model": embedding_model, + "strict": True, + "verify": True, + } + ) + + assert pending["status"] == "ok" + assert pending["query"]["chunk_kinds"] == ["routine"] + assert pending["query"]["max_text_chars"] == 1000 + assert chunk["chunk"]["kind"] == "routine" + assert chunk["text_sha1"] == indexed["text_sha1"] + assert "РассчитатьНалог" in chunk["text"] + assert stored["status"] == "ok" + assert stored["dimensions"] == 3 + assert result["status"] == "ok" + assert result["source"]["embedding_model"] == embedding_model + assert result["matches"][0]["object_ref"] == "CommonModule.Налоги" + assert result["matches"][0]["freshness"]["status"] == "cache_hit_verified" + + +def test_code_vector_external_embedding_rejects_changed_chunk(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"} + module_ref = "ConfigCAS:file1" + old_text = "Процедура Старая()\nКонецПроцедуры" + new_text = "Процедура Новая()\nКонецПроцедуры" + + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + old_index = adapter_server.code_index_upsert( + config, + base_id="upo_test", + table="ConfigCAS", + file_name="file1", + module_ref=module_ref, + data=b"old", + text=old_text, + verified=True, + ) + adapter_server.code_vector_upsert_chunks(config, {**old_index, "module_ref": module_ref, "text": old_text}) + old_chunk = adapter_server.metadata_code_vector_pending( + {"base_id": "upo_test", "embedding_model": "test:model", "limit": 1} + )["chunks"][0] + + new_index = adapter_server.code_index_upsert( + config, + base_id="upo_test", + table="ConfigCAS", + file_name="file1", + module_ref=module_ref, + data=b"new", + text=new_text, + verified=True, + ) + adapter_server.code_vector_upsert_chunks(config, {**new_index, "module_ref": module_ref, "text": new_text}) + rejected = adapter_server.metadata_code_vector_embedding_upsert( + { + "base_id": "upo_test", + "chunk_id": old_chunk["chunk_id"], + "text_sha1": old_chunk["text_sha1"], + "embedding_model": "test:model", + "embedding": [1.0, 0.0], + } + ) + + assert rejected["status"] == "conflict" + assert rejected["error"] == "chunk_missing_or_changed" + + +def test_code_vector_search_strict_refreshes_and_drops_stale_text(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"} + module_ref = "ConfigCAS:file1" + + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", lambda *args, **kwargs: (b"new-payload", config, None)) + monkeypatch.setattr( + adapter_server, + "current_code_index_text_from_sql", + lambda **kwargs: ("Процедура СовсемДругая()\nКонецПроцедуры", {"status": "ok"}), + ) + indexed = adapter_server.code_index_upsert( + config, + base_id="upo_test", + table="ConfigCAS", + file_name="file1", + module_ref=module_ref, + data=b"old-payload", + text="Процедура ЗаменаДомена()\nКонецПроцедуры", + owner={"owner_kind": "CommonModule", "owner_name": "СтарыйМодуль"}, + verified=True, + ) + adapter_server.code_vector_upsert_chunks( + config, + {**indexed, "module_ref": module_ref, "text": "Процедура ЗаменаДомена()\nКонецПроцедуры"}, + ) + + result = adapter_server.metadata_code_vector_search( + {"base_id": "upo_test", "query": "ЗаменаДомена", "verify": True, "strict": True} + ) + + assert result["status"] == "not_found" + assert result["matches"] == [] + assert result["counts"]["refreshed_modules"] >= 1 + assert adapter_server.code_index_state_status(config)["status"] == "clean" + + +def test_code_vector_search_uses_live_fallback_while_new_file_is_dirty(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"} + + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + monkeypatch.setattr( + adapter_server, + "search_modules", + lambda payload: { + "schema": "onec_module_search.v1", + "status": "ok", + "matches": [{"snippet": {"text": "НоваяПроцедура"}, "owner": {"kind": "CommonModule", "name": "Новый"}}], + "counts": {"matches": 1}, + }, + ) + adapter_server.code_index_mark_dirty( + config, + reason="saved_state_payload_apply", + source_table="ConfigCASSave", + file_names=["new-file"], + ) + + result = adapter_server.metadata_code_vector_search( + {"base_id": "upo_test", "query": "НоваяПроцедура", "verify": True, "strict": True} + ) + + assert result["status"] == "ok" + assert result["matches"] == [] + assert result["freshness"]["status"] == "dirty_with_live_fallback" + assert result["freshness"]["snapshot_token"] is None + assert result["counts"]["live_fallback_matches"] == 1 + assert result["live_fallback"]["freshness"]["status"] == "live_sql_verified" + + +def test_code_vector_search_saved_overlay_shadows_active_module(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"} + text = "Процедура НайтиДомен()\nКонецПроцедуры" + + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + + def read_file(_base_id: str, table: str, _file_name: str, **_kwargs): + return (table.encode("utf-8"), config, None) + + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", read_file) + monkeypatch.setattr( + adapter_server, + "current_code_index_text_from_sql", + lambda **kwargs: (text, {"status": "ok"}), + ) + for table, owner_name in (("ConfigCAS", "Основной"), ("ConfigCASSave", "Сохраненный")): + module_ref = f"{table}:file1" + indexed = adapter_server.code_index_upsert( + config, + base_id="upo_test", + table=table, + file_name="file1", + module_ref=module_ref, + data=table.encode("utf-8"), + text=text, + owner={"owner_kind": "CommonModule", "owner_name": owner_name}, + verified=True, + ) + adapter_server.code_vector_upsert_chunks( + config, + {**indexed, "module_ref": module_ref, "text": text}, + ) + + result = adapter_server.metadata_code_vector_search( + {"base_id": "upo_test", "query": "НайтиДомен", "verify": True, "strict": True, "limit": 10} + ) + + assert result["status"] == "ok" + assert result["counts"]["shadowed_candidates"] >= 1 + assert {match["owner"]["name"] for match in result["matches"]} == {"Сохраненный"} + assert all(match["read_selector"]["module_ref"].startswith("ConfigCASSave:") for match in result["matches"]) + + +def test_saved_overlay_tombstone_hides_active_module(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"} + text = "Процедура УдаленнаяВСохранении()\nКонецПроцедуры" + module_ref = "ConfigCAS:file1" + + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + indexed = adapter_server.code_index_upsert( + config, + base_id="upo_test", + table="ConfigCAS", + file_name="file1", + module_ref=module_ref, + data=b"active", + text=text, + verified=True, + ) + adapter_server.code_vector_upsert_chunks(config, {**indexed, "module_ref": module_ref, "text": text}) + adapter_server.code_index_overlay_tombstone_set( + config, + source_table="ConfigCASSave", + file_name="file1", + reason="saved_overlay_contains_no_indexable_bsl", + ) + + result = adapter_server.metadata_code_vector_search( + {"base_id": "upo_test", "query": "УдаленнаяВСохранении", "verify": True, "strict": True} + ) + + assert result["status"] == "not_found" + assert result["matches"] == [] + assert result["counts"]["shadowed_candidates"] >= 1 + + +def test_saved_state_invalidation_preserves_index_and_marks_target_dirty(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"} + module_ref = "ConfigCASSave:file1" + text = "Процедура Проверка()\nКонецПроцедуры" + + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + indexed = adapter_server.code_index_upsert( + config, + base_id="upo_test", + table="ConfigCASSave", + file_name="file1", + module_ref=module_ref, + data=b"payload", + text=text, + verified=True, + ) + adapter_server.code_vector_upsert_chunks(config, {**indexed, "module_ref": module_ref, "text": text}) + + result = adapter_server.invalidate_adapter_caches_after_saved_state_change( + "upo_test", + reason="test_change", + source_table="ConfigCASSave", + file_names=["file1"], + sync_code_index=False, + ) + with adapter_server.cache_connection() as conn: + modules = conn.execute("SELECT COUNT(*) AS count FROM metadata_code_index_cache").fetchone()["count"] + + assert result["status"] == "ok" + assert result["code_index"]["status"] == "dirty" + assert result["code_index"]["pending_events"] == 1 + assert result["code_index"]["snapshot_token"] is None + assert modules == 1 + + +def test_code_index_sync_pending_removes_deleted_saved_overlay(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"} + module_ref = "ConfigCASSave:file1" + text = "Процедура БольшеНеСохранена()\nКонецПроцедуры" + + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + indexed = adapter_server.code_index_upsert( + config, + base_id="upo_test", + table="ConfigCASSave", + file_name="file1", + module_ref=module_ref, + data=b"saved", + text=text, + verified=True, + ) + adapter_server.code_vector_upsert_chunks(config, {**indexed, "module_ref": module_ref, "text": text}) + adapter_server.code_index_mark_dirty( + config, + reason="external_saved_overlay_removed", + source_table="ConfigCASSave", + file_names=["file1"], + ) + monkeypatch.setattr( + adapter_server, + "metadata_code_index_build", + lambda payload: { + "schema": "onec_code_index_build.v1", + "status": "ok", + "counts": {"scanned_files": 0, "indexed": 0}, + }, + ) + + result = adapter_server.metadata_code_index_sync_pending({"base_id": "upo_test"}) + with adapter_server.cache_connection() as conn: + modules = conn.execute("SELECT COUNT(*) AS count FROM metadata_code_index_cache").fetchone()["count"] + vectors = conn.execute("SELECT COUNT(*) AS count FROM metadata_code_vector_cache").fetchone()["count"] + + assert result["status"] == "ok" + assert result["counts"]["removed_modules"] == 1 + assert result["counts"]["remaining_events"] == 0 + assert result["freshness"]["snapshot_token"] + assert modules == 0 + assert vectors == 0 + + +def test_code_index_poll_changes_discovers_new_external_saved_file(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"} + + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + monkeypatch.setattr( + adapter_server, + "storage_files_list", + lambda payload: { + "schema": "onec_storage_files.v1", + "status": "ok", + "files": [{"FileName": "external-new"}], + }, + ) + + def sync_pending(_payload: dict) -> dict: + state = adapter_server.code_index_mark_indexed( + config, + source_table="ConfigCASSave", + file_names=["external-new"], + ) + return { + "schema": "onec_code_index_sync_pending.v1", + "status": "ok", + "counts": {"remaining_events": state["pending_events"]}, + "freshness": {"index_state": state, "snapshot_token": state["snapshot_token"]}, + } + + monkeypatch.setattr(adapter_server, "metadata_code_index_sync_pending", sync_pending) + + result = adapter_server.metadata_code_index_poll_changes( + {"base_id": "upo_test", "table": "ConfigCASSave", "max_items": 10} + ) + + assert result["status"] == "ok" + assert result["counts"]["new_files"] == 1 + assert result["counts"]["remaining_events"] == 0 + assert result["freshness"]["snapshot_token"] + assert result["tables"][0]["coverage_complete"] is True + + +def test_code_index_poll_changes_removes_external_deleted_saved_file(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"} + module_ref = "ConfigCASSave:deleted-file" + text = "Процедура УдаленаСнаружи()\nКонецПроцедуры" + + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (config, None)) + monkeypatch.setattr( + adapter_server, + "storage_files_list", + lambda payload: {"schema": "onec_storage_files.v1", "status": "ok", "files": []}, + ) + indexed = adapter_server.code_index_upsert( + config, + base_id="upo_test", + table="ConfigCASSave", + file_name="deleted-file", + module_ref=module_ref, + data=b"saved", + text=text, + verified=True, + ) + adapter_server.code_vector_upsert_chunks(config, {**indexed, "module_ref": module_ref, "text": text}) + + result = adapter_server.metadata_code_index_poll_changes( + {"base_id": "upo_test", "table": "ConfigCASSave", "max_items": 10} + ) + with adapter_server.cache_connection() as conn: + modules = conn.execute("SELECT COUNT(*) AS count FROM metadata_code_index_cache").fetchone()["count"] + + assert result["status"] == "ok" + assert result["counts"]["removed_files"] == 1 + assert result["counts"]["remaining_events"] == 0 + assert modules == 0 def test_extension_common_form_read_selector_points_to_form_decode() -> None: @@ -2430,6 +3861,7 @@ def test_form_command_button_write_existing_workflow_returns_semantic_verify_and "code_index_refresh_form_embedded_module", lambda **kwargs: {"schema": "onec_code_index_refresh_form_module.v1", "status": "updated", "routine_count": 1}, ) + monkeypatch.setattr(adapter_server, "repository_apply_gate", lambda *args, **kwargs: None) result = adapter_server.metadata_form_command_button_write( { @@ -2754,6 +4186,7 @@ def test_form_write_target_verify_reports_prepare_needed(monkeypatch: pytest.Mon "kind": "CommonForm", "name": "t_Форма", "extension": "test2", + "form": "t_Форма", "mode": "plan", } assert result["saved_state_search"]["counts"]["forms"] == 0 @@ -5674,6 +7107,592 @@ def test_metadata_object_form_details_supports_common_form(monkeypatch: pytest.M assert seen["payload"]["include_storage"] is False +def test_form_field_lineage_exact_mode_filters_related_price_variants(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, object] = {} + + def fake_form_details(payload: dict[str, object]) -> dict[str, object]: + seen["payload"] = payload + return { + "status": "ok", + "forms": [{ + "name": "ФормаДокумента", + "elements": [ + {"name": "ВидыКоличествЦенаПродажи", "type_name": "Поле ввода"}, + {"name": "ВидыКоличествЦенаПродажиСНДС", "type_name": "Поле ввода"}, + {"name": "ВидыКоличествЦенаПродажиРасширеннаяПодсказка", "type_name": "Расширенная подсказка"}, + ], + "attributes": [], "commands": [], "dynamic_lists": [], + }], + } + + monkeypatch.setattr( + adapter_server, + "metadata_object_form_details", + fake_form_details, + ) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", + "field": "ВидыКоличествЦенаПродажи", "match_mode": "exact", + "include_call_chain": False, "include_calculation": False, "include_binding_inference": False, + }) + + assert result["status"] == "ok" + assert [item["item"]["name"] for item in result["matches"]] == ["ВидыКоличествЦенаПродажи"] + assert result["matches"][0]["match_by"] == "exact" + assert "calculation" not in result + assert seen["payload"]["include_parameters"] is False + + +def test_form_field_lineage_rejects_non_boolean_and_unbounded_trace_arguments() -> None: + invalid_bool = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "Цена", + "include_call_chain": "false", + }) + invalid_depth = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "Цена", + "call_chain_depth": 4, + }) + + assert invalid_bool["status"] == "invalid_argument" + assert invalid_bool["argument"] == "include_call_chain" + assert invalid_depth["status"] == "invalid_argument" + assert invalid_depth["argument"] == "call_chain_depth" + + +def test_form_field_lineage_does_not_infer_sales_price_for_generic_price(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, "metadata_object_form_details", + lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаДокумента", + "elements": [ + {"name": "ЦенаЗакупки", "type_name": "Поле ввода"}, + {"name": "ЦенаПродажи", "type_name": "Поле ввода"}, + ], + "attributes": [], "commands": [], "dynamic_lists": [], + }]}, + ) + monkeypatch.setattr(adapter_server, "code_read", lambda payload: pytest.fail("price calculation must not be inferred")) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "kind": "Document", "name": "ПланированиеМеню", "field": "Цена", + "include_call_chain": False, "include_binding_inference": False, + }) + + assert result["status"] == "ok" + assert "calculation" not in result + assert result["match_summary"]["count"] == 2 + assert result["match_summary"]["ambiguous"] is True + assert result["match_summary"]["recommended_match_mode"] == "exact" + assert [item["field"] for item in result["match_summary"]["candidates"]] == ["ЦенаЗакупки", "ЦенаПродажи"] + assert [item["params"]["field"] for item in result["match_summary"]["next_calls"]] == ["ЦенаЗакупки", "ЦенаПродажи"] + assert all(item["params"]["match_mode"] == "exact" for item in result["match_summary"]["next_calls"]) + assert result["match_summary"]["next_calls"][0]["params"]["kind"] == "Document" + assert result["match_summary"]["next_calls"][0]["params"]["name"] == "ПланированиеМеню" + + +def test_form_field_lineage_filters_by_decoded_form_title(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, "metadata_object_form_details", + lambda payload: {"status": "ok", "forms": [ + {"name": "FormA", "title": "Планирование производства", "elements": [{"name": "Цена", "type_name": "Поле ввода"}], "attributes": [], "commands": [], "dynamic_lists": []}, + {"name": "FormB", "title": "Продажи", "elements": [{"name": "Цена", "type_name": "Поле ввода"}], "attributes": [], "commands": [], "dynamic_lists": []}, + ]}, + ) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "form_title": "Планирование производства", + "field": "Цена", "match_mode": "exact", "include_call_chain": False, "include_calculation": False, "include_binding_inference": False, + }) + + assert result["status"] == "ok" + assert [item["form"] for item in result["matches"]] == ["FormA"] + assert result["matches"][0]["form_title_match"] == "confirmed" + assert result["form_title_filter"]["status"] == "confirmed" + + +def test_form_field_lineage_reports_unverifiable_form_title_filter(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаДокумента", "elements": [{"name": "ДатаПлана", "type_name": "Поле ввода"}], + "attributes": [], "commands": [], "dynamic_lists": [], + }]}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "form_title": "Планирование производства", + "field": "ДатаПлана", "match_mode": "exact", "include_call_chain": False, + "include_calculation": False, "include_binding_inference": False, + }) + + assert result["status"] == "ok" + assert result["matches"][0]["form_title_match"] == "unavailable" + assert result["form_title_filter"]["status"] == "partial" + assert result["form_title_filter"]["unavailable_forms"] == ["ФормаДокумента"] + + +def test_form_field_lineage_title_diagnostic_lists_only_matching_forms(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [ + {"name": "ФормаДокумента", "elements": [{"name": "ДатаПлана"}], "attributes": [], "commands": [], "dynamic_lists": []}, + {"name": "ФормаСписка", "elements": [{"name": "Номер"}], "attributes": [], "commands": [], "dynamic_lists": []}, + ]}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "form_title": "Планирование производства", + "field": "ДатаПлана", "match_mode": "exact", "include_call_chain": False, + "include_calculation": False, "include_binding_inference": False, + }) + + assert result["form_title_filter"]["unavailable_forms"] == ["ФормаДокумента"] + + +def test_form_field_lineage_exact_ambiguity_provides_form_scoped_next_calls(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [ + {"name": "ФормаДокумента", "elements": [{"name": "ДатаПлана"}], "attributes": [{"name": "ДатаПлана"}], "commands": [], "dynamic_lists": []}, + {"name": "ФормаСписка", "elements": [{"name": "ДатаПлана"}], "attributes": [], "commands": [], "dynamic_lists": []}, + ]}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "ДатаПлана", + "match_mode": "exact", "include_call_chain": False, + "include_calculation": False, "include_binding_inference": False, + }) + + assert result["match_summary"]["ambiguous"] is True + assert [call["params"]["form"] for call in result["match_summary"]["next_calls"]] == ["ФормаДокумента", "ФормаСписка"] + + +def test_form_field_lineage_reads_dynamic_list_column_and_query(monkeypatch: pytest.MonkeyPatch) -> None: + query = "ВЫБРАТЬ Номенклатура.Себестоимость КАК FoodCost ИЗ Справочник.Номенклатура КАК Номенклатура" + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаСписка", "elements": [], "commands": [], "dynamic_lists": [], + "attributes": [{ + "name": "Список", "dynamic_list_settings": {"query_text": query}, + "dynamic_list_fields": [{"name": "FoodCost", "data_name": "FoodCost", "path_to_data": "Список.FoodCost"}], + }], + }]}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "FoodCost", + "match_mode": "exact", "include_call_chain": False, + "include_calculation": False, "include_binding_inference": False, + }) + + match = result["matches"][0] + assert match["section"] == "dynamic_list_fields" + assert match["evidence"] == "decoded_dynamic_list_field" + assert match["item"]["dynamic_list"] == "Список" + assert match["item"]["data_path"] == "Список.FoodCost" + assert match["item"]["expression"] == "Номенклатура.Себестоимость" + assert match["item"]["query_text"] == query + assert match["item"]["query_sources"] == [{"object_ref": "Справочник.Номенклатура", "alias": "Номенклатура"}] + assert match["item"]["source_references"] == [{ + "alias": "Номенклатура", "field": "Себестоимость", + "source": "Справочник.Номенклатура", "public_path": "Справочник.Номенклатура.Себестоимость", + }] + assert match["item"]["query_lineage"] == { + "status": "confirmed", "parser": "simple_select_alias_v1", + "output_field": "FoodCost", "expression": "Номенклатура.Себестоимость", "binding_kind": "direct_field", + } + assert match["item"]["origin_graph"] == { + "status": "confirmed", + "nodes": [ + {"id": "form_field:Список.FoodCost", "kind": "form_dynamic_list_field", "name": "FoodCost", "data_path": "Список.FoodCost"}, + {"id": "query_output:Список.FoodCost", "kind": "query_output", "name": "FoodCost", "expression": "Номенклатура.Себестоимость"}, + {"id": "source:Справочник.Номенклатура.Себестоимость", "kind": "metadata_field", "public_path": "Справочник.Номенклатура.Себестоимость", "source": "Справочник.Номенклатура", "field": "Себестоимость"}, + ], + "edges": [ + {"from": "form_field:Список.FoodCost", "to": "query_output:Список.FoodCost", "kind": "dynamic_list_column_binding"}, + {"from": "query_output:Список.FoodCost", "to": "source:Справочник.Номенклатура.Себестоимость", "kind": "query_source_reference"}, + ], + } + + +def test_form_read_methods_accept_isolated_job_mode(monkeypatch: pytest.MonkeyPatch) -> None: + started: list[dict[str, Any]] = [] + monkeypatch.setattr(adapter_server, "adapter_start_job", lambda request: started.append(request) or {"status": "queued", "job_id": "job-form"}) + + details = adapter_server.metadata_object_form_details({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "execution_mode": "job", "timeout_seconds": 17, + }) + decoded = adapter_server.metadata_form_decode({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "execution_mode": "job", "timeout_seconds": 19, + }) + lineage = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "Цена", "execution_mode": "job", "timeout_seconds": 21, + }) + + assert details == {"status": "queued", "job_id": "job-form"} + assert decoded == {"status": "queued", "job_id": "job-form"} + assert lineage == {"status": "queued", "job_id": "job-form"} + assert [request["method"] for request in started] == ["metadata.object.form.details", "metadata.form.decode", "form.field.lineage"] + assert [request["timeout_seconds"] for request in started] == [17, 19, 21] + assert [request["payload"]["execution_mode"] for request in started if "execution_mode" in request["payload"]] == [] + assert [request["payload"]["ref"] for request in started] == ["Document.ПланированиеМеню", "Document.ПланированиеМеню", "Document.ПланированиеМеню"] + + +def test_form_item_coverage_separates_auxiliary_records() -> None: + summary = form_item_coverage_summary([ + {"type_name": "Поле ввода", "semantic": {"coverage": {"mapped": 4, "unmapped": 2}}}, + {"type_name": "Контекстное меню", "semantic": {"coverage": {"mapped": 1, "unmapped": 20}}}, + {"type_name": "Расширенная подсказка", "semantic": {"coverage": {"mapped": 1, "unmapped": 10}}}, + ]) + + assert summary == { + "all_items": {"items": 3, "mapped": 6, "unmapped": 32, "total": 38, "status": "partial"}, + "interactive_items": {"items": 1, "mapped": 4, "unmapped": 2, "total": 6, "status": "partial"}, + "auxiliary_items": {"items": 2, "mapped": 2, "unmapped": 30, "total": 32, "status": "partial"}, + } + + +def test_form_write_mode_is_not_forwarded_to_internal_form_decode() -> None: + assert adapter_server.without_form_decode_selector_keys({ + "base_id": "upo_test", "form": "Тест", "element": "Поле", + "execution_mode": "apply_and_rollback", "mode": "apply_and_rollback", + }) == {"base_id": "upo_test", "form": "Тест"} + + +def test_form_field_lineage_marks_derived_dynamic_list_query_as_partial(monkeypatch: pytest.MonkeyPatch) -> None: + query = ( + "ВЫБРАТЬ Позиции.Себестоимость КАК FoodCost " + "ИЗ (ВЫБРАТЬ Номенклатура.Себестоимость КАК Себестоимость " + "ИЗ Справочник.Номенклатура КАК Номенклатура) КАК Позиции" + ) + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаСписка", "elements": [], "commands": [], "dynamic_lists": [], + "attributes": [{ + "name": "Список", "dynamic_list_settings": {"query_text": query}, + "dynamic_list_fields": [{"name": "FoodCost", "data_name": "FoodCost"}], + }], + }]}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "FoodCost", + "match_mode": "exact", "include_call_chain": False, + "include_calculation": False, "include_binding_inference": False, + }) + + lineage = result["matches"][0]["item"]["query_lineage"] + assert lineage["status"] == "partial" + assert lineage["diagnostics"] == ["complex_query_not_fully_traced"] + assert lineage["complex_constructs"] == ["nested_query"] + assert result["matches"][0]["item"]["source_references"] == [{ + "alias": "Позиции", "field": "Себестоимость", + "source": "Справочник.Номенклатура", "public_path": "Справочник.Номенклатура.Себестоимость", + "derived_from": {"alias": "Позиции", "field": "Себестоимость"}, + "resolution": "derived_table_projection", + }] + + +def test_form_field_lineage_connects_visual_element_to_dynamic_list_alias(monkeypatch: pytest.MonkeyPatch) -> None: + query = "ВЫБРАТЬ Номенклатура.Себестоимость КАК FoodCost ИЗ Справочник.Номенклатура КАК Номенклатура" + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаПланирования", "commands": [], "dynamic_lists": [], + "elements": [{"name": "Себестоимость", "path_to_data": "Список.FoodCost"}], + "attributes": [{ + "name": "Список", "dynamic_list_settings": {"query_text": query}, + "dynamic_list_fields": [{"name": "FoodCost", "data_name": "FoodCost"}], + }], + }]}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "Себестоимость", + "match_mode": "exact", "include_call_chain": False, + "include_calculation": False, "include_binding_inference": False, + }) + + binding = result["matches"][0]["item"]["dynamic_list_binding"] + assert binding["status"] == "confirmed" + assert binding["data_path"] == "Список.FoodCost" + assert binding["field"] == "FoodCost" + assert binding["query_lineage"]["status"] == "confirmed" + assert binding["origin_graph"]["nodes"][0] == { + "id": "form_element:Себестоимость", "kind": "form_element", + "name": "Себестоимость", "data_path": "Список.FoodCost", + } + assert binding["origin_graph"]["edges"][0] == { + "from": "form_element:Себестоимость", "to": "form_field:FoodCost", + "kind": "visual_element_dynamic_list_binding", "evidence": "decoded_form_data_path", + } + + +def test_form_field_lineage_marks_dynamic_list_calculation_and_its_inputs(monkeypatch: pytest.MonkeyPatch) -> None: + query = ( + "ВЫБРАТЬ ВЫБОР КОГДА Номенклатура.Цена > 0 ТОГДА Номенклатура.Цена " + "ИНАЧЕ Номенклатура.Себестоимость КОНЕЦ КАК FoodCost " + "ИЗ Справочник.Номенклатура КАК Номенклатура" + ) + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаСписка", "elements": [], "commands": [], "dynamic_lists": [], + "attributes": [{"name": "Список", "dynamic_list_settings": {"query_text": query}, + "dynamic_list_fields": [{"name": "FoodCost"}]}], + }]}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "FoodCost", + "match_mode": "exact", "include_call_chain": False, + "include_calculation": False, "include_binding_inference": False, + }) + + item = result["matches"][0]["item"] + assert item["query_lineage"]["status"] == "partial" + assert item["query_lineage"]["binding_kind"] == "calculated_expression" + assert item["query_lineage"]["diagnostics"] == ["calculated_expression"] + assert item["source_references"] == [ + {"alias": "Номенклатура", "field": "Цена", "source": "Справочник.Номенклатура", "public_path": "Справочник.Номенклатура.Цена"}, + {"alias": "Номенклатура", "field": "Себестоимость", "source": "Справочник.Номенклатура", "public_path": "Справочник.Номенклатура.Себестоимость"}, + ] + + +def test_form_field_lineage_returns_dynamic_list_query_context(monkeypatch: pytest.MonkeyPatch) -> None: + query = ( + "ВЫБРАТЬ Номенклатура.Цена КАК Цена ИЗ Справочник.Номенклатура КАК Номенклатура " + "ГДЕ Номенклатура.Вид = &Вид И Номенклатура.ПометкаУдаления = ЛОЖЬ " + "УПОРЯДОЧИТЬ ПО Номенклатура.Наименование" + ) + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаСписка", "elements": [], "commands": [], "dynamic_lists": [], + "attributes": [{"name": "Список", "dynamic_list_settings": {"query_text": query}, + "dynamic_list_fields": [{"name": "Цена"}]}], + }]}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "Цена", + "match_mode": "exact", "include_call_chain": False, + "include_calculation": False, "include_binding_inference": False, + }) + + assert result["matches"][0]["item"]["query_context"] == { + "parameters": ["Вид"], + "filter": "Номенклатура.Вид = &Вид И Номенклатура.ПометкаУдаления = ЛОЖЬ", + "order_by": "Номенклатура.Наименование", + "status": "static_query_context", + } + + +def test_form_field_lineage_infers_dynamic_list_parameter_from_form_attribute(monkeypatch: pytest.MonkeyPatch) -> None: + query = "ВЫБРАТЬ Номенклатура.Цена КАК Цена ИЗ Справочник.Номенклатура КАК Номенклатура ГДЕ Номенклатура.Вид = &ВидЦен" + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаСписка", "elements": [{"name": "ВидЦен", "path_to_data": "ВидЦен"}], "commands": [], "dynamic_lists": [], + "attributes": [ + {"name": "Список", "dynamic_list_settings": {"query_text": query}, "dynamic_list_fields": [{"name": "Цена"}]}, + {"name": "ВидЦен", "path_to_data": "Настройки.ВидЦен"}, + ], + }]}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "Цена", + "match_mode": "exact", "include_call_chain": False, + "include_calculation": False, "include_binding_inference": False, + }) + + assert result["matches"][0]["item"]["query_context"]["parameter_bindings"] == [{ + "parameter": "ВидЦен", "status": "inferred", "source": "decoded_name_or_data_path", + "candidates": [ + {"kind": "form_attribute", "name": "ВидЦен", "data_path": "Настройки.ВидЦен"}, + {"kind": "form_element", "name": "ВидЦен", "data_path": "ВидЦен"}, + ], + }] + + +def test_form_field_lineage_describes_join_and_virtual_register_source(monkeypatch: pytest.MonkeyPatch) -> None: + query = ( + "ВЫБРАТЬ Цены.Цена КАК Цена ИЗ Справочник.Номенклатура КАК Номенклатура " + "ЛЕВОЕ СОЕДИНЕНИЕ РегистрСведений.ЦеныНоменклатуры.СрезПоследних(&Дата) КАК Цены " + "ПО Цены.Номенклатура = Номенклатура.Ссылка ГДЕ Номенклатура.ПометкаУдаления = ЛОЖЬ" + ) + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаСписка", "elements": [], "commands": [], "dynamic_lists": [], + "attributes": [{"name": "Список", "dynamic_list_settings": {"query_text": query}, + "dynamic_list_fields": [{"name": "Цена"}]}], + }]}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "Цена", + "match_mode": "exact", "include_call_chain": False, + "include_calculation": False, "include_binding_inference": False, + }) + + item = result["matches"][0]["item"] + assert item["source_references"] == [{ + "alias": "Цены", "field": "Цена", "source": "РегистрСведений.ЦеныНоменклатуры.СрезПоследних", + "public_path": "РегистрСведений.ЦеныНоменклатуры.СрезПоследних.Цена", + }] + assert item["query_context"]["joins"] == [{ + "kind": "левое", "source": "РегистрСведений.ЦеныНоменклатуры.СрезПоследних", + "alias": "Цены", "condition": "Цены.Номенклатура = Номенклатура.Ссылка", + }] + assert item["query_context"]["virtual_sources"] == [{ + "alias": "Цены", "source": "РегистрСведений.ЦеныНоменклатуры.СрезПоследних", + "register": "РегистрСведений.ЦеныНоменклатуры", "virtual_table": "СрезПоследних", "arguments": "&Дата", + }] + + +def test_form_field_lineage_lists_each_union_branch_source(monkeypatch: pytest.MonkeyPatch) -> None: + query = ( + "ВЫБРАТЬ Основные.Цена КАК Цена ИЗ Справочник.Номенклатура КАК Основные " + "ОБЪЕДИНИТЬ ВСЕ ВЫБРАТЬ Акционные.ЦенаАкции КАК Цена " + "ИЗ РегистрСведений.АкционныеЦены КАК Акционные" + ) + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаСписка", "elements": [], "commands": [], "dynamic_lists": [], + "attributes": [{"name": "Список", "dynamic_list_settings": {"query_text": query}, + "dynamic_list_fields": [{"name": "Цена"}]}], + }]}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "Цена", + "match_mode": "exact", "include_call_chain": False, + "include_calculation": False, "include_binding_inference": False, + }) + + lineage = result["matches"][0]["item"]["query_lineage"] + assert lineage["status"] == "partial" + assert lineage["diagnostics"] == ["complex_query_not_fully_traced"] + assert lineage["union_branches"] == [ + {"branch": 1, "status": "confirmed", "expression": "Основные.Цена", + "source_references": [{"alias": "Основные", "field": "Цена", "source": "Справочник.Номенклатура", "public_path": "Справочник.Номенклатура.Цена"}]}, + {"branch": 2, "status": "confirmed", "expression": "Акционные.ЦенаАкции", + "source_references": [{"alias": "Акционные", "field": "ЦенаАкции", "source": "РегистрСведений.АкционныеЦены", "public_path": "РегистрСведений.АкционныеЦены.ЦенаАкции"}]}, + ] + + +def test_form_field_lineage_resolves_simple_temporary_table_projection(monkeypatch: pytest.MonkeyPatch) -> None: + query = ( + "ВЫБРАТЬ Номенклатура.Цена КАК Цена ПОМЕСТИТЬ ВТЦены " + "ИЗ Справочник.Номенклатура КАК Номенклатура; " + "ВЫБРАТЬ Цены.Цена КАК Цена ИЗ ВТЦены КАК Цены" + ) + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаСписка", "elements": [], "commands": [], "dynamic_lists": [], + "attributes": [{"name": "Список", "dynamic_list_settings": {"query_text": query}, + "dynamic_list_fields": [{"name": "Цена"}]}], + }]}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "Цена", + "match_mode": "exact", "include_call_chain": False, + "include_calculation": False, "include_binding_inference": False, + }) + + item = result["matches"][0]["item"] + assert item["query_lineage"]["status"] == "partial" + assert item["source_references"] == [{ + "alias": "Цены", "field": "Цена", "source": "Справочник.Номенклатура", + "public_path": "Справочник.Номенклатура.Цена", + "derived_from": {"temporary_table": "ВТЦены", "field": "Цена"}, + "resolution": "temporary_table_projection", + }] + assert item["query_context"]["temporary_tables"] == [{ + "name": "ВТЦены", "fields": [{"field": "Цена", "source": "Справочник.Номенклатура", "source_field": "Цена"}], + }] + + +def test_form_field_lineage_confirms_tabular_binding_from_element_name(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаДокумента", "elements": [{"name": "ПродукцияСебестоимость", "type_name": "Поле ввода"}], + "attributes": [], "commands": [], "dynamic_lists": [], + }]}) + monkeypatch.setattr(adapter_server, "metadata_object_attributes", lambda payload: {"status": "ok", "tabular_sections": [{ + "name": "Продукция", "columns": [{"name": "Себестоимость", "synonym": "Себестоимость", "type": {"kind": "number"}}], + }]}) + monkeypatch.setattr(adapter_server, "code_read", lambda payload: {"status": "not_found"}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "ПродукцияСебестоимость", + "match_mode": "exact", "include_call_chain": False, + }) + + assert result["matches"][0]["item"]["binding"]["object_member_ref"] == "Объект.Продукция.Себестоимость" + assert result["matches"][0]["item"]["binding"]["member"]["type"]["kind"] == "number" + assert result["match_summary"]["binding_inference_count"] == 1 + + +def test_form_field_lineage_confirms_object_attribute_binding_from_element_name(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаДокумента", "elements": [{"name": "ДатаПлана", "type_name": "Поле ввода"}], + "attributes": [], "commands": [], "dynamic_lists": [], + }]}) + monkeypatch.setattr(adapter_server, "metadata_object_attributes", lambda payload: { + "status": "ok", "tabular_sections": [], + "attributes": [{"name": "ДатаПлана", "synonym": "Дата плана", "type": {"kind": "date"}}], + }) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "ДатаПлана", + "match_mode": "exact", "include_call_chain": False, "include_calculation": False, + }) + + binding = result["matches"][0]["item"]["binding"] + assert binding["object_member_ref"] == "Объект.ДатаПлана" + assert binding["member"]["type"]["kind"] == "date" + assert binding["source"] == "element_name + decoded_object_attribute" + + +def test_form_field_lineage_prioritizes_decoded_form_data_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаДокумента", "elements": [{"name": "ДатаПлана", "path_to_data": "Объект.ДатаПлана"}], + "attributes": [], "commands": [], "dynamic_lists": [], + }]}) + monkeypatch.setattr(adapter_server, "metadata_object_attributes", lambda payload: pytest.fail("direct form path must not need name inference")) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "ДатаПлана", + "match_mode": "exact", "include_call_chain": False, "include_calculation": False, + }) + + item = result["matches"][0]["item"] + assert item["data_path"] == "Объект.ДатаПлана" + assert item["binding"] == { + "status": "confirmed", "object_member_ref": "Объект.ДатаПлана", "source": "decoded_form_data_path", + } + + +def test_form_field_lineage_exact_mode_accepts_logical_form_data_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "metadata_object_form_details", lambda payload: {"status": "ok", "forms": [{ + "name": "ФормаДокумента", "elements": [{"name": "ДатаПлана", "path_to_data": "Объект.ДатаПлана"}], + "attributes": [], "commands": [], "dynamic_lists": [], + }]}) + + result = adapter_server.form_field_lineage({ + "base_id": "upo_test", "ref": "Document.ПланированиеМеню", "field": "Объект.ДатаПлана", + "match_mode": "exact", "include_call_chain": False, "include_calculation": False, + }) + + assert result["status"] == "ok" + assert result["matches"][0]["match_by"] == "exact" + assert result["matches"][0]["item"]["binding"]["object_member_ref"] == "Объект.ДатаПлана" + + +def test_form_field_lineage_contract_exposes_precise_and_lightweight_modes() -> None: + schema = adapter_server.adapter_method_registry()["form.field.lineage"]["input_schema"] + + assert schema["required"] == ["base_id", "field"] + assert schema["properties"]["match_mode"]["enum"] == ["contains", "exact", "semantic"] + assert schema["properties"]["include_calculation"]["type"] == "boolean" + assert schema["properties"]["call_chain_depth"]["maximum"] == 3 + + +def test_public_sanitizer_keeps_logical_form_data_path_but_hides_storage_path() -> None: + public = adapter_server.sanitize_public_result({ + "data_path": "Объект.ДатаПлана", + "storage_path": "Config/9/0", + }) + + assert public == {"data_path": "Объект.ДатаПлана"} + + +def test_public_sanitizer_keeps_followup_request_but_hides_internal_payload() -> None: + public = adapter_server.sanitize_public_result({ + "follow_up": [{ + "method": "metadata.code_index.search", + "request": {"base_id": "upo_test", "ref": "Document.СписаниеЗапасов"}, + "payload": {"internal": "must_not_escape"}, + }], + }) + + assert public["follow_up"][0]["request"]["ref"] == "Document.СписаниеЗапасов" + assert "payload" not in public["follow_up"][0] + + def test_form_object_data_paths_are_resolved_from_metadata_identity_guids() -> None: order_guid = "21e8237e-5d20-4726-8712-3d43bfc9993e" author_guid = "977014d5-3fac-4bdf-9d20-81b437053042" @@ -7895,6 +9914,35 @@ def test_identity_matching_repairs_latin1_cp1251_mojibake() -> None: assert adapter_server.extension_definition_identity_match_by({"name": mojibake_name, "synonyms": {}}, name) == "name_exact" +def test_public_metadata_rows_repair_latin1_cp1251_names() -> None: + name = "АвансовыйОтчетПрисоединенныеФайлы" + mojibake_name = name.encode("cp1251").decode("latin1") + + live = adapter_server.public_metadata_row( + { + "guid": "11111111-2222-3333-4444-555555555555", + "kind": "Catalog", + "name": mojibake_name, + "identity": {"name": mojibake_name, "synonyms": {"ru": mojibake_name}}, + } + ) + cached = adapter_server.metadata_cache_public_row( + { + "guid": "11111111-2222-3333-4444-555555555555", + "kind": "Catalog", + "name": mojibake_name, + "synonym": mojibake_name, + } + ) + + assert live["name"] == name + assert live["ref"] == f"Catalog.{name}" + assert live["identity"]["name"] == name + assert live["identity"]["synonyms"]["ru"] == name + assert cached["name"] == name + assert cached["synonym"] == name + + def test_extension_objects_find_uses_manifest_descriptor_route(monkeypatch: pytest.MonkeyPatch) -> None: descriptor_key = "a" * 40 part_key = "b" * 40 @@ -8310,6 +10358,87 @@ def test_metadata_object_attributes_reads_saved_extension_register_fields(monkey assert calls[0]["table"] == "ConfigCASSave" +def test_metadata_object_templates_includes_decoded_data_composition_schema(monkeypatch: pytest.MonkeyPatch) -> None: + schema_guid = "11111111-1111-1111-1111-111111111111" + monkeypatch.setattr( + adapter_server, + "metadata_object_related", + lambda payload: { + "status": "ok", + "object": {"kind": "Report", "name": "Продажи"}, + "related": [{ + "category": "DataCompositionSchema", "status": "ok", "guid": schema_guid, + "identity": {"guid": schema_guid, "name": "ОсновнаяСхемаКомпоновкиДанных", "synonyms": {"ru": "Основная схема компоновки данных"}}, + }], + "counts": {"related": 1}, + }, + ) + monkeypatch.setattr(adapter_server, "metadata_object_parts", lambda payload: {"status": "ok", "parts": []}) + + result = adapter_server.metadata_object_templates( + {"base_id": "upo_test", "ref": "Отчет.Продажи", "template": "ОсновнаяСхемаКомпоновкиДанных"} + ) + + assert result["status"] == "ok" + assert result["counts"]["templates"] == 1 + assert result["templates"][0]["name"] == "ОсновнаяСхемаКомпоновкиДанных" + assert result["templates"][0]["kind"] == "data_composition_schema" + assert result["templates"][0]["match_by"] == "name_exact" + + +def test_templates_bindings_preserves_data_composition_schema_kind(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_object_templates", + lambda payload: { + "status": "ok", "object": {"kind": "Report", "name": "Продажи"}, "source": {"kind": "live_metadata"}, + "templates": [{"name": "ОсновнаяСхемаКомпоновкиДанных", "kind": "data_composition_schema", "format": "НеОпределено"}], + "counts": {"templates": 1}, + }, + ) + monkeypatch.setattr(adapter_server, "metadata_code_index_search", lambda payload: {"status": "not_found", "matches": []}) + monkeypatch.setattr(adapter_server, "search_modules", lambda payload: {"status": "not_found", "matches": []}) + + result = adapter_server.templates_bindings( + {"base_id": "upo_test", "ref": "Отчет.Продажи", "template": "ОсновнаяСхемаКомпоновкиДанных"} + ) + + assert result["status"] == "ok" + assert result["bindings"][0]["template"]["kind"] == "data_composition_schema" + assert result["bindings"][0]["format"] == "data_composition_schema" + + +def test_template_field_lineage_routes_selected_scd_directly(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "templates_bindings", + lambda payload: { + "status": "ok", "object": {"kind": "Report", "name": "Продажи"}, + "bindings": [{"template": {"name": "ОсновнаяСхемаКомпоновкиДанных", "kind": "data_composition_schema"}}], + }, + ) + seen_payload: dict[str, Any] = {} + + def fake_dcs_field_lineage(payload: dict[str, Any]) -> dict[str, Any]: + seen_payload.update(payload) + return { + "status": "ok", "lineage": [{"field": "Цена", "expression": "Цены.Цена"}], + "agent_summary": {"confirmed_sources": [{"source": "РегистрСведений.Цены.Цена"}]}, + } + + monkeypatch.setattr(adapter_server, "dcs_field_lineage", fake_dcs_field_lineage) + + result = adapter_server.template_field_lineage( + {"base_id": "upo_test", "ref": "Отчет.Продажи", "template": "ОсновнаяСхемаКомпоновкиДанных", "field": "Цена", "resolve_scd": True} + ) + + assert result["status"] == "confirmed" + assert result["lineage"][0]["resolution"] == "direct_scd_field_lineage" + assert result["source_context_candidates"][0]["confirmed_sources"] == [{"source": "РегистрСведений.Цены.Цена"}] + assert seen_payload["schema"] == "ОсновнаяСхемаКомпоновкиДанных" + assert seen_payload["report"] == "Продажи" + + def test_metadata_object_related_reads_saved_extension_child_with_internal_prefix(monkeypatch: pytest.MonkeyPatch) -> None: extension_guid = "fb26cf42-7609-11f1-828f-005056b0d483" object_guid = "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5" @@ -8529,6 +10658,77 @@ def test_metadata_object_commands_forwards_saved_extension_selector(monkeypatch: assert get_calls[0]["extension_guid"] == extension_guid +def test_metadata_object_commands_resolves_public_extension_before_object_probe(monkeypatch: pytest.MonkeyPatch) -> None: + extension_guid = "fb26cf42-7609-11f1-828f-005056b0d483" + object_guid = "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5" + get_calls: list[dict[str, Any]] = [] + + monkeypatch.setattr( + adapter_server, + "extension_filter_to_guid", + lambda base_id, extension, method: (extension_guid, None), + ) + + def fake_get_object(kind: str | None, name: str, **kwargs: Any) -> dict[str, Any]: + get_calls.append(kwargs) + return {"status": "ok", "object": {"kind": "Report", "name": "Отчет", "guid": object_guid}} + + monkeypatch.setattr(adapter_server, "get_object", fake_get_object) + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (None, None)) + monkeypatch.setattr( + adapter_server, + "metadata_object_related", + lambda payload: {"status": "ok", "related": [], "counts": {"related": 0, "by_category": {}}}, + ) + + result = adapter_server.metadata_object_commands( + { + "base_id": "upo_test", + "kind": "Report", + "name": "Отчет", + "extension": "РасширениеОтчетов", + "include_form_commands": False, + } + ) + + assert result["status"] == "ok" + assert get_calls[0]["extension_guid"] == extension_guid + assert get_calls[0]["table"] == "ConfigCAS" + + +def test_metadata_object_commands_keeps_resolved_extension_for_child_decoder(monkeypatch: pytest.MonkeyPatch) -> None: + extension_guid = "fb26cf42-7609-11f1-828f-005056b0d483" + object_guid = "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5" + related_calls: list[dict[str, Any]] = [] + + monkeypatch.setattr( + adapter_server, + "get_object", + lambda *args, **kwargs: {"status": "ok", "object": {"kind": "Report", "name": "Отчет", "guid": object_guid}}, + ) + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (None, None)) + + def fake_related(payload: dict[str, Any]) -> dict[str, Any]: + related_calls.append(payload) + return {"status": "ok", "related": [], "counts": {"related": 0, "by_category": {}}} + + monkeypatch.setattr(adapter_server, "metadata_object_related", fake_related) + + result = adapter_server.metadata_object_commands( + { + "base_id": "upo_test", + "kind": "Report", + "guid": object_guid, + "table": "ConfigCAS", + "extension_guid": extension_guid, + "include_form_commands": False, + } + ) + + assert result["status"] == "ok" + assert related_calls[0]["extension_guid"] == extension_guid + + def test_metadata_object_modules_forwards_saved_extension_selector(monkeypatch: pytest.MonkeyPatch) -> None: extension_guid = "fb26cf42-7609-11f1-828f-005056b0d483" object_guid = "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5" @@ -8567,6 +10767,173 @@ def test_metadata_object_modules_forwards_saved_extension_selector(monkeypatch: assert get_calls[0]["extension_guid"] == extension_guid +def test_metadata_object_modules_auto_resolves_exact_extension_object(monkeypatch: pytest.MonkeyPatch) -> None: + extension_guid = "fb26cf42-7609-11f1-828f-005056b0d483" + object_guid = "e7e322f5-a7a6-4ddc-bad0-344e75ad00d0" + get_calls: list[dict[str, Any]] = [] + + def fake_get_object(kind: str | None, name: str, **kwargs: Any) -> dict[str, Any]: + get_calls.append({"kind": kind, "name": name, **kwargs}) + return {"status": "ok", "object": {"kind": "Catalog", "name": "test2", "guid": object_guid}} + + monkeypatch.setattr(adapter_server, "get_object", fake_get_object) + monkeypatch.setattr( + adapter_server, + "fast_extension_object_exact_match", + lambda *args, **kwargs: { + "kind": "Catalog", + "name": "test2", + "guid": object_guid, + "origin": {"extension": {"name": "test2", "guid": extension_guid}}, + "route": {"table": "ConfigCAS", "file_name": "b" * 40}, + }, + ) + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (None, None)) + monkeypatch.setattr( + adapter_server, + "metadata_object_forms", + lambda payload: {"status": "ok", "forms": []}, + ) + monkeypatch.setattr( + adapter_server, + "metadata_object_parts", + lambda payload: { + "status": "ok", + "object": {"kind": "Catalog", "name": "test2", "guid": object_guid}, + "source": {"kind": "live_metadata"}, + "parts": [], + "counts": {"parts": 0, "roles": {}}, + }, + ) + + result = adapter_server.metadata_object_modules( + {"base_id": "upo_test", "kind": "Catalog", "name": "test2"} + ) + + assert result["status"] == "ok" + assert len(get_calls) == 1 + assert get_calls[0]["table"] == "ConfigCAS" + assert get_calls[0]["extension_guid"] == extension_guid + assert get_calls[0]["file_name"] == "b" * 40 + + +def test_modules_search_object_scoped_extension_skips_global_owner_discovery(monkeypatch: pytest.MonkeyPatch) -> None: + extension_guid = "fb26cf42-7609-11f1-828f-005056b0d483" + module_calls: list[dict[str, Any]] = [] + + monkeypatch.setattr( + adapter_server, + "extension_filter_to_guid", + lambda base_id, extension, method: (extension_guid, None), + ) + monkeypatch.setattr( + adapter_server, + "extension_definition_guid_sources", + lambda *args, **kwargs: pytest.fail("global extension owner discovery must be skipped"), + ) + + def fake_modules(payload: dict[str, Any]) -> dict[str, Any]: + module_calls.append(payload) + return { + "status": "ok", + "object": {"kind": "Catalog", "name": "test2", "guid": "e7e322f5-a7a6-4ddc-bad0-344e75ad00d0"}, + "modules": [], + } + + monkeypatch.setattr(adapter_server, "metadata_object_modules", fake_modules) + monkeypatch.setattr( + adapter_server, + "metadata_object_commands", + lambda payload: {"status": "ok", "object_commands": []}, + ) + + result = adapter_server.search_modules( + { + "base_id": "upo_test", + "extension": "test2", + "kind": "Catalog", + "name": "test2", + "query": "ЗаменаДомена", + "state": "active", + } + ) + + assert result["status"] == "ok" + assert result["counts"]["matches"] == 0 + assert module_calls[0]["extension_guid"] == extension_guid + assert module_calls[0]["table"] == "ConfigCAS" + + +def test_modules_search_working_prefers_saved_extension_form_counterpart(monkeypatch: pytest.MonkeyPatch) -> None: + active_ref = "ConfigCAS:" + ("a" * 40) + "#form_module" + saved_ref = "ConfigCASSave:fb26cf42-7609-11f1-828f-005056b0d483__77494708-43ea-4956-ac3c-199cfb035ad2.0#form_module" + reads: list[tuple[str, str]] = [] + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: (None, None)) + monkeypatch.setattr( + adapter_server, + "metadata_object_modules", + lambda payload: { + "status": "ok", + "object": { + "kind": "Catalog", + "name": "test2", + "guid": "e7e322f5-a7a6-4ddc-bad0-344e75ad00d0", + "origin": {"extension": {"name": "test2", "guid": "fb26cf42-7609-11f1-828f-005056b0d483"}}, + }, + "modules": [ + { + "module_id": active_ref, + "kind": "form_module", + "form": {"name": "t_Форма", "guid": "77494708-43ea-4956-ac3c-199cfb035ad2"}, + } + ], + }, + ) + monkeypatch.setattr(adapter_server, "metadata_object_commands", lambda payload: {"status": "ok", "object_commands": []}) + monkeypatch.setattr( + adapter_server, + "code_read_saved_counterpart_module_ref", + lambda base_id, module_ref, payload: saved_ref, + ) + + def fake_read(base_id: str, table: str, file_name: str, **kwargs: Any) -> tuple[bytes, dict[str, Any], None]: + reads.append((table, file_name)) + text = ( + "Процедура ЗаменаДомена()\n// Отредактировано адаптером\nКонецПроцедуры" + if table == "ConfigCASSave" + else "Процедура ЗаменаДомена()\n// active\nКонецПроцедуры" + ) + return text.encode("utf-8-sig"), {}, None + + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", fake_read) + + working = adapter_server.search_modules( + { + "base_id": "upo_test", + "kind": "Catalog", + "name": "test2", + "query": "Отредактировано адаптером", + "state": "working", + } + ) + active = adapter_server.search_modules( + { + "base_id": "upo_test", + "kind": "Catalog", + "name": "test2", + "query": "Отредактировано адаптером", + "state": "active", + } + ) + + assert working["status"] == "ok" + assert working["matches"][0]["read_selector"]["module_ref"] == saved_ref + assert working["matches"][0]["activation_state"] == "saved_state" + assert working["matches"][0]["current_state"] == {"source": "saved_state", "activation_state": "not_activated"} + assert active["matches"] == [] + assert reads[0][0] == "ConfigCASSave" + + def test_metadata_object_commands_enriches_cached_saved_command_with_read_selector(monkeypatch: pytest.MonkeyPatch) -> None: extension_guid = "fb26cf42-7609-11f1-828f-005056b0d483" object_guid = "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5" @@ -8771,6 +11138,47 @@ def test_metadata_object_parts_uses_saved_extension_storage_prefix(monkeypatch: assert list_calls[0]["prefix"] == file_name +def test_metadata_object_parts_reads_extension_report_manifest_parts(monkeypatch: pytest.MonkeyPatch) -> None: + report_guid = "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5" + manifest_entries = [ + {"suffix": "", "cas_key": "report-descriptor"}, + {"suffix": ".0", "cas_key": "report-part-0"}, + {"suffix": ".1", "cas_key": "report-part-1"}, + ] + + monkeypatch.setattr(adapter_server, "extension_filter_to_guid", lambda *args, **kwargs: ("11111111-1111-1111-1111-111111111111", None)) + monkeypatch.setattr( + adapter_server, + "fast_extension_object_exact_match", + lambda *args, **kwargs: { + "guid": report_guid, + "kind": "Report", + "name": "фс_ПродовольственнаяКорзина", + "synonym": "Продовольственная корзина", + "origin": {"source": "extension", "extension": {"name": "фс_Отчеты"}}, + "route": {"table": "ConfigCAS", "file_name": "report-descriptor"}, + "manifest_entries": manifest_entries, + }, + ) + monkeypatch.setattr( + adapter_server, + "read_storage_files_bytes", + lambda base_id, table, names, **kwargs: ({name: b"{1}" for name in names}, {"database": "upo_test"}, None), + ) + monkeypatch.setattr(adapter_server, "resolve_object_guid", lambda *args, **kwargs: pytest.fail("manifest report must not use prefix discovery")) + + result = adapter_server.metadata_object_parts( + {"base_id": "upo_test", "extension": "фс_Отчеты", "kind": "Report", "name": "фс_ПродовольственнаяКорзина", "include_storage": True} + ) + + assert result["status"] == "ok" + assert result["source"]["table"] == "ConfigCAS" + assert result["object"]["kind"] == "Report" + assert result["counts"]["parts"] == 3 + assert [part["suffix"] for part in result["parts"]] == ["", ".0", ".1"] + assert result["diagnostics"]["extension_manifest"]["status"] == "resolved" + + def test_metadata_object_decode_reads_saved_extension_descriptor(monkeypatch: pytest.MonkeyPatch) -> None: extension_guid = "fb26cf42-7609-11f1-828f-005056b0d483" object_guid = "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5" @@ -10713,6 +13121,7 @@ def test_get_object_reads_extension_data_processor_from_configcas(monkeypatch: p "Обработки", "ТестоваяОбработка", base_id="upo_test", + table="ConfigCAS", include_semantic=False, include_storage=True, ) @@ -10724,6 +13133,38 @@ def test_get_object_reads_extension_data_processor_from_configcas(monkeypatch: p assert read_tables == ["ConfigCAS"] +def test_get_object_prefers_exact_name_over_cached_synonym(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda base_id: ({"database": "upo_test"}, None)) + monkeypatch.setattr(adapter_server, "fast_extension_object_exact_match", lambda *args, **kwargs: None) + monkeypatch.setattr( + adapter_server, + "metadata_cache_lookup_row", + lambda *args, **kwargs: { + "guid": "old-guid", "kind": "CommonModule", "name": "УОП_ЦенообразованиеСервер", + "synonym": "Ценообразование сервер", "source": "base", + }, + ) + seen: dict[str, Any] = {} + + def fake_list_objects(kind: str, **kwargs: Any) -> dict[str, Any]: + seen.update(kwargs) + return {"status": "ok", "objects": [{ + "guid": "exact-guid", "kind": "CommonModule", "name": "ЦенообразованиеСервер", + "synonym": "Ценообразование сервер", "source": "base", + "ref": "CommonModule.ЦенообразованиеСервер", + }]} + + monkeypatch.setattr(adapter_server, "list_objects", fake_list_objects) + + result = adapter_server.get_object("CommonModule", "ЦенообразованиеСервер", base_id="upo_test", include_semantic=False) + + assert result["status"] == "ok" + assert result["object"]["guid"] == "exact-guid" + assert result["object"]["name"] == "ЦенообразованиеСервер" + assert seen["refresh_cache"] is True + assert seen["name_filter"] == "ЦенообразованиеСервер" + + def test_get_object_falls_back_to_extension_definition_for_data_processor(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(adapter_server, "live_dbnames_records", lambda *args, **kwargs: ([], None)) monkeypatch.setattr(adapter_server, "metadata_cache_lookup_row", lambda *args, **kwargs: None) @@ -10748,6 +13189,7 @@ def test_get_object_falls_back_to_extension_definition_for_data_processor(monkey "Обработки", "ТестоваяОбработка", base_id="upo_test", + table="ConfigCAS", include_semantic=False, include_storage=True, ) @@ -11104,6 +13546,77 @@ def test_definition_find_direct_call_accepts_object_type_name_aliases(monkeypatc assert seen_get_object == {"kind": "DataProcessor", "name_or_guid": "ТестоваяОбработка"} +def test_definition_find_fast_report_field_uses_scd_lineage_before_broad_search(monkeypatch: pytest.MonkeyPatch) -> None: + seen_lineage_payload: dict[str, Any] = {} + monkeypatch.setattr( + adapter_server, + "get_object", + lambda *args, **kwargs: { + "status": "ok", + "object": { + "kind": "Report", + "kind_ru": "Отчет", + "name": "Продажи", + "guid": "33333333-3333-3333-3333-333333333333", + }, + }, + ) + def fake_dcs_field_lineage(payload: dict[str, Any]) -> dict[str, Any]: + seen_lineage_payload.update(payload) + return { + "status": "ok", + "lineage": [{"dataset": "НаборДанных1", "field": "Цена", "expression": "Продажи.Цена"}], + "agent_summary": {"status": "ok", "confirmed_sources": [{"source": "РегистрСведений.Цены.Цена"}]}, + } + + monkeypatch.setattr(adapter_server, "dcs_field_lineage", fake_dcs_field_lineage) + monkeypatch.setattr( + adapter_server, + "metadata_object_attributes", + lambda payload: pytest.fail("fast SCD route must not start broad object search"), + ) + + result = adapter_server.metadata_definition_find( + { + "base_id": "upo_test", + "ref": "Отчет.Продажи", + "query": "Цена", + "search_profile": "fast", + "template": "ОсновнаяСхемаКомпоновкиДанных", + } + ) + + assert result["status"] == "ok" + assert result["query"]["areas"] == ["scd"] + assert result["matches"][0]["match_by"] == "scd_field_static" + assert result["matches"][0]["confirmed_sources"] == [{"source": "РегистрСведений.Цены.Цена"}] + assert seen_lineage_payload["schema"] == "ОсновнаяСхемаКомпоновкиДанных" + assert result["matches"][0]["read_selector"]["schema"] == "ОсновнаяСхемаКомпоновкиДанных" + assert result["next_calls"][0]["params"]["search_profile"] == "full" + + +def test_definition_find_accepts_explicit_scd_area(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "get_object", + lambda *args, **kwargs: {"status": "ok", "object": {"kind": "Report", "name": "Продажи", "guid": "33333333-3333-3333-3333-333333333333"}}, + ) + monkeypatch.setattr( + adapter_server, + "dcs_field_lineage", + lambda payload: {"status": "not_found", "error": "field_not_found", "diagnostics": {"code": "SCD_FIELD_NOT_FOUND"}}, + ) + + result = adapter_server.metadata_definition_find( + {"base_id": "upo_test", "ref": "Отчет.Продажи", "query": "Цена", "areas": ["схема компоновки данных"]} + ) + + assert result["status"] == "not_found" + assert result["error"] == "field_not_found" + assert result["query"]["areas"] == ["scd"] + assert result["diagnostics"][0]["area"] == "scd" + + def test_metadata_adapter_audit_reports_recognized_missing_and_unmapped(monkeypatch: pytest.MonkeyPatch) -> None: class Record: def __init__(self, guid: str, storage_role: str) -> None: @@ -11295,9 +13808,8 @@ def test_modules_search_repairs_saved_state_stream_and_reports_owner_role(monkey assert result["status"] == "ok" assert result["counts"]["matches"] == 1 match = result["matches"][0] - assert match["owner"]["name"] == "tt_Справочник1" - assert match["module"]["kind"] == "manager_module" - assert match["module"]["name"] == "Модуль менеджера" + assert match["owner"]["status"] == "unresolved" + assert match["module"]["kind"] != "manager_module" assert match["activation_state"] == "saved_state" assert match["read_selector"]["module_ref"] == f"ConfigCASSave:{file_name}#stream:0" @@ -11577,6 +14089,39 @@ 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_pushes_exact_file_filter_into_sql_equality( + monkeypatch: pytest.MonkeyPatch, +) -> None: + file_name = "fb26cf42-7609-11f1-828f-005056b0d483__77494708-43ea-4956-ac3c-199cfb035ad2.0" + seen: list[dict[str, Any]] = [] + + def fake_storage_files_list(payload: dict[str, Any]) -> dict[str, Any]: + seen.append(dict(payload)) + return {"status": "ok", "files": [{"FileName": file_name, "PartCount": 1, "Bytes": 1}]} + + monkeypatch.setattr(adapter_server, "storage_files_list", fake_storage_files_list) + monkeypatch.setattr( + adapter_server, + "saved_state_module_search_row", + lambda **_kwargs: {"source": {"table": "ConfigCASSave", "file_name": file_name}, "streams": []}, + ) + + result = adapter_server.metadata_saved_state_modules_search( + { + "base_id": "upo_test", + "tables": ["ConfigCASSave"], + "file_name": file_name, + "limit": 1, + "scan_limit": 10, + "include_storage": True, + } + ) + + assert result["counts"]["modules"] == 1 + assert seen[0]["exact_file_name"] == file_name + assert "prefix" not in seen[0] + + def test_saved_state_modules_search_accepts_semantic_layer(monkeypatch: pytest.MonkeyPatch) -> None: seen_tables: list[str] = [] @@ -12734,12 +15279,21 @@ def test_code_read_state_both_treats_saved_module_ref_as_saved_layer(monkeypatch def fake_read_module(payload: dict[str, Any]) -> dict[str, Any]: seen_payloads.append(dict(payload)) - assert payload["module_ref"] == "ConfigCASSave:object__module.0#stream:4" + if payload.get("state") == "save": + assert payload["module_ref"] == "ConfigCASSave:object__module.0#stream:4" + return { + "schema": "onec_module_read.v1", + "status": "ok", + "text": "Процедура ИзSave()\nКонецПроцедуры\n", + "origin": {"source": "saved_state"}, + } + assert payload["state"] == "active" + assert payload["module_ref"] == "ConfigCAS:object__module.0#stream:4" return { "schema": "onec_module_read.v1", "status": "ok", - "text": "Процедура ИзSave()\nКонецПроцедуры\n", - "origin": {"source": "saved_state"}, + "text": "Процедура ИзActive()\nКонецПроцедуры\n", + "origin": {"source": "extension"}, } monkeypatch.setattr(adapter_server, "read_module", fake_read_module) @@ -12752,18 +15306,58 @@ def test_code_read_state_both_treats_saved_module_ref_as_saved_layer(monkeypatch } ) - assert len(seen_payloads) == 1 + assert len(seen_payloads) == 2 assert seen_payloads[0]["state"] == "save" assert result["status"] == "ok" assert result["layers"][0]["source"] == "saved_state" assert result["layers"][0]["status"] == "ok" assert result["layers"][1]["source"] == "active" - assert result["layers"][1]["status"] == "not_found" - assert result["layers"][1]["error"] == "active_selector_required" + assert result["layers"][1]["status"] == "ok" + assert result["comparison"]["both_present"] is True + assert result["comparison"]["differs"] is True assert result["text_source"] == "saved_state" -def test_code_read_state_both_removes_saved_module_ref_for_active_layer(monkeypatch: pytest.MonkeyPatch) -> None: +def test_code_read_state_both_maps_active_form_ref_to_saved_form_ref(monkeypatch: pytest.MonkeyPatch) -> None: + active_ref = "ConfigCAS:active-form-cas-key#form_module" + saved_ref = "ConfigCASSave:fb26cf42-7609-11f1-828f-005056b0d483__77494708-43ea-4956-ac3c-199cfb035ad2.0#form_module" + seen_payloads: list[dict[str, Any]] = [] + + monkeypatch.setattr( + adapter_server, + "code_read_saved_counterpart_module_ref", + lambda base_id, module_ref, payload: saved_ref if module_ref == active_ref else None, + ) + + def fake_read_module(payload: dict[str, Any]) -> dict[str, Any]: + seen_payloads.append(dict(payload)) + if payload.get("state") == "save": + assert payload["module_ref"] == saved_ref + return {"schema": "onec_module_read.v1", "status": "ok", "text": "Процедура Тест()\n\t// saved\nКонецПроцедуры\n"} + assert payload["state"] == "active" + assert payload["module_ref"] == active_ref + return {"schema": "onec_module_read.v1", "status": "ok", "text": "Процедура Тест()\n\t// active\nКонецПроцедуры\n"} + + monkeypatch.setattr(adapter_server, "read_module", fake_read_module) + + result = adapter_server.code_read( + { + "base_id": "upo_test", + "kind": "Catalog", + "name": "test2", + "form": "t_Форма", + "module_ref": active_ref, + "state": "both", + } + ) + + assert [payload["state"] for payload in seen_payloads] == ["save", "active"] + assert result["comparison"]["both_present"] is True + assert result["comparison"]["differs"] is True + assert result["text_source"] == "saved_state" + + +def test_code_read_state_both_prefers_direct_active_counterpart_before_owner_selector(monkeypatch: pytest.MonkeyPatch) -> None: seen_payloads: list[dict[str, Any]] = [] def fake_read_module(payload: dict[str, Any]) -> dict[str, Any]: @@ -12771,8 +15365,8 @@ def test_code_read_state_both_removes_saved_module_ref_for_active_layer(monkeypa if payload.get("state") == "save": return {"schema": "onec_module_read.v1", "status": "ok", "text": "Процедура ИзSave()\nКонецПроцедуры\n"} assert payload["state"] == "active" - assert "module_ref" not in payload - assert "module_id" not in payload + assert payload["module_ref"] == "ConfigCAS:object__module.0#stream:4" + assert payload["module_id"] == "ConfigCAS:object__module.0#stream:4" assert payload["kind"] == "CommonModule" assert payload["name"] == "ТестовыйМодуль" return {"schema": "onec_module_read.v1", "status": "ok", "text": "Процедура ИзActive()\nКонецПроцедуры\n"} @@ -12796,6 +15390,90 @@ def test_code_read_state_both_removes_saved_module_ref_for_active_layer(monkeypa assert result["layers"][1]["text"].startswith("Процедура ИзActive") +def test_code_read_state_both_falls_back_to_logical_owner_when_direct_counterpart_is_absent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + extension_guid = "11111111-1111-1111-1111-111111111111" + owner_guid = "22222222-2222-2222-2222-222222222222" + saved_ref = f"ConfigCASSave:{extension_guid}__module-guid.0#stream:4" + seen_payloads: list[dict[str, Any]] = [] + + def fake_read_module(payload: dict[str, Any]) -> dict[str, Any]: + seen_payloads.append(dict(payload)) + if payload.get("state") == "save": + return { + "schema": "onec_module_read.v1", + "status": "ok", + "text": "Процедура ИзSave()\nКонецПроцедуры\n", + "owner": { + "status": "resolved", + "kind": "CommonModule", + "name": "ТестовыйМодуль", + "guid": owner_guid, + }, + "module": {"kind": "manager_module", "name": "Модуль менеджера"}, + } + if payload.get("module_ref"): + assert payload["module_ref"] == f"ConfigCAS:{extension_guid}__module-guid.0#stream:4" + return {"schema": "onec_module_read.v1", "status": "source_missing", "error": "source_missing"} + assert payload["table"] == "ConfigCAS" + assert payload["kind"] == "CommonModule" + assert payload["name"] == "ТестовыйМодуль" + assert payload["guid"] == owner_guid + assert payload["extension_guid"] == extension_guid + assert payload["module"] == "Модуль менеджера" + return { + "schema": "onec_module_read.v1", + "status": "ok", + "text": "Процедура ИзActive()\nКонецПроцедуры\n", + } + + monkeypatch.setattr(adapter_server, "read_module", fake_read_module) + + result = adapter_server.code_read( + { + "base_id": "upo_test", + "module_ref": saved_ref, + "state": "both", + } + ) + + assert len(seen_payloads) == 3 + assert result["status"] == "ok" + assert result["layers"][1]["status"] == "ok" + assert result["layers"][1]["counterpart_resolution"]["strategy"] == "logical_owner_and_module_role" + assert result["comparison"]["both_present"] is True + assert result["comparison"]["differs"] is True + + +def test_code_read_compare_returns_text_hashes_without_exposing_text(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_read_module(payload: dict[str, Any]) -> dict[str, Any]: + text = ( + "Процедура ИзSave()\nКонецПроцедуры\n" + if payload.get("state") == "save" + else "Процедура ИзActive()\nКонецПроцедуры\n" + ) + return {"schema": "onec_module_read.v1", "status": "ok", "text": text} + + monkeypatch.setattr(adapter_server, "read_module", fake_read_module) + + result = adapter_server.code_read( + { + "base_id": "upo_test", + "module_ref": "ConfigCASSave:object__module.0#stream:4", + "state": "both", + "include_text": False, + } + ) + + assert result["comparison"]["both_present"] is True + assert result["comparison"]["module_text_same"] is False + assert result["comparison"]["active_text_sha1"] + assert result["comparison"]["saved_text_sha1"] + assert "text" not in result + assert all("text" not in layer for layer in result["layers"]) + + def test_code_search_preserves_public_ref_in_read_selector(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( adapter_server, @@ -12832,6 +15510,48 @@ def test_code_search_preserves_public_ref_in_read_selector(monkeypatch: pytest.M assert selector["module_ordinal"] == 1 +def test_code_search_prepare_replace_returns_copy_ready_contextual_selector(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "search_modules", + lambda _payload: { + "status": "ok", + "source": {"kind": "live_metadata"}, + "matches": [ + { + "snippet": {"text": "Повтор", "offset": 0}, + "owner": {"status": "resolved", "kind": "Report", "name": "Тест"}, + "origin": {"extension": {"name": "ТестРасширение"}}, + "module": {"name": "Модуль объекта", "module_ordinal": 1}, + "read_selector": {"base_id": "upo_test", "method": "modules.read", "ref": "Report.Тест", "module_ordinal": 1}, + } + ], + "counts": {"matches": 1, "complete": True, "scan_limit_hit": False}, + "diagnostics": {}, + }, + ) + monkeypatch.setattr( + adapter_server, + "code_read", + lambda _payload: {"status": "ok", "text": "Начало\nПовтор\nУникальныйКонец", "content_sha1": "a" * 40}, + ) + + result = adapter_server.code_search( + {"base_id": "upo_test", "query": "Повтор", "prepare_replace": True, "anchor_chars": 30} + ) + + candidate = result["items"][0]["replacement_candidates"][0] + assert candidate["status"] == "ready" + assert candidate["write_selector"] == { + "base_id": "upo_test", + "ref": "Report.Тест", + "extension": "ТестРасширение", + "expected_text_sha1": "a" * 40, + } + assert candidate["old"] == "Повтор" + assert result["query"]["prepare_replace"] is True + + def test_code_search_accepts_public_ref_selector(monkeypatch: pytest.MonkeyPatch) -> None: seen_payloads: list[dict[str, Any]] = [] @@ -13105,7 +15825,242 @@ def test_adapter_job_process_timeout_terminates_child(monkeypatch: pytest.Monkey adapter_server.ADAPTER_JOBS.pop(job_id, None) +def test_definition_find_process_timeout_keeps_streamed_partial_matches(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + class FakeQueue: + def __init__(self) -> None: + self.events = [{ + "type": "set", + "updates": { + "partial_result": { + "schema": "onec_metadata_definition_find.v1", + "status": "partial", + "base_id": "upo_test", + "matches": [{"area": "modules", "name": "Найдено"}], + "counts": {"matches": 1, "by_area": {"modules": 1}}, + }, + }, + }] + + def get(self, timeout: float | None = None) -> dict[str, Any]: + if self.events: + return self.events.pop(0) + raise adapter_server.queue.Empty() + + def get_nowait(self) -> dict[str, Any]: + raise adapter_server.queue.Empty() + + def close(self) -> None: + return None + + class FakeProcess: + def __init__(self) -> None: + self.exitcode = None + self.alive = True + + def start(self) -> None: + return None + + def is_alive(self) -> bool: + return self.alive + + def terminate(self) -> None: + self.alive = False + + def join(self, timeout: float | None = None) -> None: + return None + + fake_queue = FakeQueue() + fake_process = FakeProcess() + + class FakeContext: + def Queue(self) -> FakeQueue: + return fake_queue + + def Process(self, **kwargs: Any) -> FakeProcess: + return fake_process + + monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(tmp_path / "adapter-state.sqlite")) + monkeypatch.setattr(adapter_server.multiprocessing, "get_context", lambda *_args, **_kwargs: FakeContext()) + job_id = "definition-find-partial-timeout" + now = adapter_server.adapter_now() + adapter_server.ADAPTER_JOBS[job_id] = { + "schema": "onec_adapter_job.v1", + "status": "running", + "job_id": job_id, + "method": "metadata.definition.find", + "created_at": now, + "started_at": now, + "updated_at": now, + } + try: + adapter_server.adapter_run_job_in_process(job_id, "metadata.definition.find", {"base_id": "upo_test"}, 0.001) + + job = adapter_server.ADAPTER_JOBS[job_id] + assert job["status"] == "done" + assert job["result"]["status"] == "partial" + assert job["result"]["matches"] == [{"area": "modules", "name": "Найдено"}] + assert job["result"]["counts"]["matches"] == 1 + assert job["result"]["counts"]["by_area"] == {"modules": 1} + assert job["result"]["diagnostics"][-1]["code"] == "time_budget_exhausted" + finally: + adapter_server.ADAPTER_JOBS.pop(job_id, None) + + +def test_code_index_search_process_timeout_returns_partial(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + class FakeQueue: + def get(self, timeout: float | None = None) -> dict[str, Any]: + raise adapter_server.queue.Empty() + + def get_nowait(self) -> dict[str, Any]: + raise adapter_server.queue.Empty() + + def close(self) -> None: + return None + + class FakeProcess: + exitcode = None + + def __init__(self) -> None: + self.alive = True + + def start(self) -> None: + return None + + def is_alive(self) -> bool: + return self.alive + + def terminate(self) -> None: + self.alive = False + + def join(self, timeout: float | None = None) -> None: + return None + + class FakeContext: + def Queue(self) -> FakeQueue: + return FakeQueue() + + def Process(self, **kwargs: Any) -> FakeProcess: + return FakeProcess() + + monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(tmp_path / "adapter-state.sqlite")) + monkeypatch.setattr(adapter_server.multiprocessing, "get_context", lambda *_args, **_kwargs: FakeContext()) + job_id = "code-index-partial-timeout" + now = adapter_server.adapter_now() + adapter_server.ADAPTER_JOBS[job_id] = {"schema": "onec_adapter_job.v1", "status": "running", "job_id": job_id, "method": "metadata.code_index.search", "created_at": now, "started_at": now, "updated_at": now} + try: + adapter_server.adapter_run_job_in_process(job_id, "metadata.code_index.search", {"base_id": "upo_test", "query": "needle"}, 0.001) + job = adapter_server.ADAPTER_JOBS[job_id] + assert job["status"] == "done" + assert job["result"]["schema"] == "onec_code_index_search.v1" + assert job["result"]["status"] == "partial" + assert job["result"]["diagnostics"][-1]["code"] == "time_budget_exhausted" + finally: + adapter_server.ADAPTER_JOBS.pop(job_id, None) + + +def test_member_type_evidence_exposes_only_declared_reference_target() -> None: + evidence = adapter_server.public_member_type_evidence( + [{"name": "ДокументОснование", "type": {"kind": "reference", "presentation": "ДокументСсылка.РасходнаяНакладная"}}], + [], + [], + [], + ) + + assert evidence == [{ + "member": "ДокументОснование", + "member_kind": "attribute", + "declared_type": {"kind": "reference", "presentation": "ДокументСсылка.РасходнаяНакладная"}, + "evidence": "metadata_declared_type", + "status": "confirmed_target_type", + "targets": [{ + "kind": "Document", + "name": "РасходнаяНакладная", + "ref": "Document.РасходнаяНакладная", + "presentation": "ДокументСсылка.РасходнаяНакладная", + }], + }] + + +def test_relationship_verify_rejects_target_not_declared_by_member_type(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_object_attributes", + lambda payload: { + "status": "ok", + "object": {"kind": "Document", "name": "СписаниеЗапасов", "ref": "Document.СписаниеЗапасов"}, + "member_type_evidence": [{ + "member": "ДокументОснование", + "status": "confirmed_target_type", + "targets": [{"ref": "Document.РасходнаяНакладная"}], + }], + }, + ) + + result = adapter_server.metadata_relationship_verify( + { + "base_id": "upo_test", + "ref": "Document.СписаниеЗапасов", + "member": "ДокументОснование", + "target_ref": "Document.УОП_ПланированиеМеню", + } + ) + + assert result["status"] == "not_confirmed" + assert result["relation_status"] == "declared_target_does_not_match" + assert result["declared_targets"] == [{"ref": "Document.РасходнаяНакладная"}] + assert result["diagnostics"][0]["code"] == "target_not_declared_by_member_type" + + +def test_relationship_find_returns_all_direct_declared_matches(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_object_attributes", + lambda payload: { + "status": "ok", + "object": {"kind": "Document", "name": "СписаниеЗапасов", "ref": "Document.СписаниеЗапасов"}, + "attributes": [ + {"name": "ДокументОснование", "type": {"kind": "reference", "presentation": "ДокументСсылка.РасходнаяНакладная"}}, + {"name": "СвязанныйДокумент", "type": {"kind": "reference", "presentation": "ДокументСсылка.РасходнаяНакладная"}}, + ], + "tabular_sections": [], + "dimensions": [], + "resources": [], + }, + ) + + result = adapter_server.metadata_relationship_find( + {"base_id": "upo_test", "ref": "Document.СписаниеЗапасов", "target_ref": "Document.РасходнаяНакладная"} + ) + + assert result["status"] == "confirmed" + assert [item["member"] for item in result["matches"]] == ["ДокументОснование", "СвязанныйДокумент"] + assert result["counts"] == {"matches": 2} + + +def test_relationship_find_suggests_non_confirming_static_followup(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_object_attributes", + lambda payload: { + "status": "ok", + "object": {"kind": "Document", "name": "СписаниеЗапасов", "ref": "Document.СписаниеЗапасов"}, + "attributes": [], "tabular_sections": [], "dimensions": [], "resources": [], + }, + ) + + result = adapter_server.metadata_relationship_find( + {"base_id": "upo_test", "ref": "Document.СписаниеЗапасов", "target_ref": "Document.УОП_ПланированиеМеню"} + ) + + assert result["status"] == "not_found" + assert result["follow_up"][0]["evidence_level"] == "unconfirmed_static_mentions" + assert result["follow_up"][0]["request"]["query"] == "УОП_ПланированиеМеню" + assert result["follow_up"][0]["method"] == "metadata.code_index.search" + assert [item["request"]["areas"] for item in result["follow_up"][1:]] == [["form"], ["templates"]] + + def test_attributes_cache_requires_explicit_use_cache(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + seen: dict[str, Any] = {} monkeypatch.setenv("ONEC_ADAPTER_CACHE_DB", str(tmp_path / "cache.sqlite")) config = {"server": "sql-host", "database": "upo_test", "user": "sa", "password": "secret"} object_guid = "84e4c0c3-2a21-4aba-a7b0-f92b3f2878ec" @@ -13142,7 +16097,7 @@ def test_attributes_cache_requires_explicit_use_cache(monkeypatch: pytest.Monkey "semantic": {"sections": []}, }, ) - monkeypatch.setattr(adapter_server, "resolve_type_guids", lambda *args, **kwargs: {}) + monkeypatch.setattr(adapter_server, "resolve_type_guids", lambda *args, **kwargs: (seen.update(kwargs), {})[1]) monkeypatch.setattr(adapter_server, "extension_map_by_guid", lambda base_id: {}) cached_result = adapter_server.metadata_object_attributes( @@ -13157,6 +16112,8 @@ def test_attributes_cache_requires_explicit_use_cache(monkeypatch: pytest.Monkey assert live_result["status"] == "ok" assert live_result["attributes"] == [] assert live_result["cache"]["status"] == "stored" + assert live_result["query"]["resolve_generated_types"] is False + assert seen["resolve_generated_live"] is False def test_payload_lossless_round_trip_envelopes() -> None: @@ -13399,6 +16356,27 @@ def test_replace_stream_block_updates_header_and_text() -> None: ) +def test_replace_stream_block_accepts_lf_fragment_from_code_read_and_preserves_crlf() -> None: + module_text_raw = "Процедура Команда1()\r\n\tСтарое();\r\nКонецПроцедуры\r\n" + encoded = module_text_raw.encode("utf-8-sig") + header = f"\r\n{len(encoded):08x} {len(encoded):08x} 7fffffff \r\n".encode("ascii") + payload = b"prefix" + header + encoded + + changed_payload, _edit = replace_stream_block( + payload, + 0, + replace={ + "old": "Процедура Команда1()\n\tСтарое();\nКонецПроцедуры\n", + "new": "Процедура Команда1()\n\tНовое();\nКонецПроцедуры\n", + }, + ) + text = classify_payload(compress_payload(changed_payload, "raw_deflate"), include_text=True)["stream_blocks"][0]["text"] + + assert "\tНовое();\r\n" in text + assert "Старое" not in text + assert "\n" not in text.replace("\r\n", "") + + def test_replace_stream_block_routine_edit() -> None: module_text = ( "Процедура Команда1(Команда)\r\n" @@ -13514,6 +16492,79 @@ def test_module_text_response_reports_routine_match_mode() -> None: assert case_insensitive["selection"]["match_by"] == "routine_case_insensitive" +def test_module_text_response_is_compact_for_selected_routine_by_default() -> None: + text = ( + "Процедура Первая()\nКонецПроцедуры\n\n" + "Процедура Нужная()\n\tСообщить(\"ok\");\nКонецПроцедуры\n" + ) + + compact = module_text_response(text, {"routine_name": "Нужная", "max_chars": 1000}) + expanded = module_text_response( + text, + {"routine_name": "Нужная", "max_chars": 1000, "include_routines": True, "include_summary": True}, + ) + + assert compact["text"].startswith("Процедура Нужная") + assert "routines" not in compact + assert "summary" not in compact + assert len(expanded["routines"]) == 2 + assert expanded["summary"]["routines"] == 2 + + +def test_modules_read_bare_common_module_name_returns_ready_public_selector(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "get_object", + lambda *args, **kwargs: { + "status": "ok", + "object": {"kind": "CommonModule", "name": "ОтчетыСервер", "ref": "CommonModule.ОтчетыСервер"}, + }, + ) + + result = adapter_server.read_module({"base_id": "upo_test", "module_ref": "ОтчетыСервер"}) + + assert result["status"] == "error" + assert result["error"] == "invalid_module_id" + assert result["diagnostics"]["detected_public_module"]["ref"] == "CommonModule.ОтчетыСервер" + assert result["diagnostics"]["next_call"] == { + "method": "modules.read", + "params": {"base_id": "upo_test", "ref": "CommonModule.ОтчетыСервер", "module_ordinal": 1}, + } + + +def test_diagnostics_call_chain_resolves_local_and_common_module_calls(monkeypatch: pytest.MonkeyPatch) -> None: + texts = { + ("Catalog.Тест", "Старт"): "Процедура Старт()\n\tЛокальный();\n\tОбщий.Метод();\nКонецПроцедуры", + ("Catalog.Тест", "Локальный"): "Процедура Локальный()\nКонецПроцедуры", + ("CommonModule.Общий", "Метод"): "Процедура Метод()\nКонецПроцедуры", + } + + def fake_code_read(payload: dict[str, Any]) -> dict[str, Any]: + ref = str(payload.get("ref") or "") + routine = str(payload.get("routine_name") or "") + text = texts.get((ref, routine)) + if text is None: + return {"status": "not_found", "selection": {"status": "not_found"}} + return { + "status": "ok", + "text": text, + "selection": {"routine_name": routine, "line_start": 10, "line_end": 10 + len(text.splitlines()) - 1}, + } + + monkeypatch.setattr(adapter_server, "code_read", fake_code_read) + monkeypatch.setattr(adapter_server, "metadata_resolve_overrides", lambda payload: {"status": "not_found", "chain": []}) + + result = adapter_server.diagnostics_call_chain( + {"base_id": "upo_test", "ref": "Catalog.Тест", "entry_method": "Старт", "depth": 2} + ) + + assert result["status"] == "ok" + assert result["schema"] == "onec_diagnostics_call_chain.v2" + assert {node["routine_name"] for node in result["nodes"]} == {"Старт", "Локальный", "Метод"} + assert len([edge for edge in result["edges"] if edge["status"] == "resolved"]) == 2 + assert result["counts"]["unresolved_calls"] == 0 + + def test_modules_read_falls_back_to_single_bsl_stream_in_saved_command_payload(monkeypatch: pytest.MonkeyPatch) -> None: class FakeStreamClassifier: pass @@ -13809,6 +16860,32 @@ def test_modules_read_skips_extension_owner_scan_for_saved_state_stream_ref(monk assert result["module"]["stream_index"] == 4 +def test_code_read_runtime_module_ref_skips_owner_scan_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_read_module(payload: dict[str, Any]) -> dict[str, Any]: + seen.update(payload) + return {"status": "ok", "text": "Процедура Тест()\nКонецПроцедуры", "module_id": payload["module_ref"]} + + monkeypatch.setattr(adapter_server, "read_module", fake_read_module) + monkeypatch.setattr( + adapter_server, + "enrich_code_read_logical_owner", + lambda *args, **kwargs: pytest.fail("direct runtime module_ref must not run logical-owner resolution"), + ) + + result = adapter_server.code_read( + { + "base_id": "upo_test", + "module_ref": "ConfigCAS:module-file#stream:1", + "configuration_view": "runtime_applied", + } + ) + + assert result["status"] == "ok" + assert seen["resolve_owner"] is False + + def test_modules_read_resolves_extension_owner_for_configcas_stream_ref(monkeypatch: pytest.MonkeyPatch) -> None: module_ref = "ConfigCAS:12d98fe77f46c4310958c30f7fe87ef8abaf3d32#stream:4" owner_guid = "84e4c0c3-2a21-4aba-a7b0-f92b3f2878ec" @@ -13914,7 +16991,8 @@ def test_mcp_onec_request_examples_are_generic_and_selector_oriented() -> None: assert "extension.objects.find" in examples_json assert "" in examples_json assert "" in examples_json - assert "" in examples_json + assert "" in examples_json + assert "module_ref" not in examples_json assert "owner_scan_limit" in examples_json assert '"source_state": "working"' in examples_json assert "code.write" in examples_json @@ -13932,7 +17010,7 @@ def test_mcp_onec_request_examples_are_generic_and_selector_oriented() -> None: assert "metadata.form.command_button.write" in onec_request["description"] 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"] + assert "ref, kind/name, or object_type/object_name" in onec_request["description"] examples = { example["method"]: example["payload"] @@ -13982,6 +17060,24 @@ def test_mcp_source_state_maps_to_adapter_state_for_working_methods() -> None: assert explicit["state"] == "save" +def test_mcp_storage_methods_are_reported_as_direct_fresh_reads() -> None: + transformed = adapter_mcp.apply_freshness_request_policy( + {"base_id": "upo_test", "diagnostic": True}, + "storage.saved_state.backups.prune", + ) + enriched = adapter_mcp.enrich_result_with_freshness( + {"base_id": "upo_test", "diagnostic": True}, + "storage.saved_state.backups.prune", + {"status": "ok", "dry_run": True}, + time.time(), + ) + + assert transformed["cache_policy"] == "none" + assert transformed["force_refresh"] is True + assert enriched["_freshness"]["status"] == "fresh" + assert enriched["_freshness"]["cache_policy"] == "none" + + def test_mcp_code_write_uses_working_saved_state_without_agent_sql_gates() -> None: transformed = adapter_mcp.apply_freshness_request_policy( { @@ -14025,6 +17121,29 @@ def test_mcp_blocks_metadata_write_bsl_and_suggests_code_write() -> None: assert "allow_saved_state_write" not in suggested["payload"] +def test_mcp_code_write_suggestion_does_not_treat_module_as_1c_owner_type() -> None: + result = adapter_mcp.run_or_enqueue_adapter_method( + "metadata.module.write_apply", + { + "base_id": "upo_test", + "target": { + "kind": "module", + "name": "Модуль объекта", + "module_ref": "ConfigCASSave:object__module.0#stream:4", + }, + "module_text": "Процедура Тест()\nКонецПроцедуры\n", + "mode": "apply", + }, + ) + + suggested_payload = result["diagnostics"]["suggested_request"]["payload"] + assert result["reason"] == "use_code_write_for_bsl" + assert suggested_payload["module_ref"] == "ConfigCASSave:object__module.0#stream:4" + assert "object_type" not in suggested_payload + assert "object_name" not in suggested_payload + assert "object_guid" not in suggested_payload + + def test_mcp_source_does_not_advertise_extension_filter_for_metadata_objects_list() -> None: source = Path(adapter_mcp.__file__).read_text(encoding="utf-8") start = source.index('"method": {"const": "metadata.objects.list"}') @@ -14130,6 +17249,16 @@ def test_mcp_selector_chain_smoke_uses_generic_placeholders() -> None: ] assert code_search_steps assert all((step.get("payload") or {}).get("source_state") == "working" for step in code_search_steps) + form_chain = next(chain for chain in report["chains"] if chain["name"] == "report_to_separate_form_description") + form_steps = form_chain["steps"] + assert [step["method"] for step in form_steps] == [ + "metadata.object.forms", + "metadata.object.form.details", + "metadata.form.decode", + ] + assert all(step["payload"]["source_state"] == "working" for step in form_steps) + assert form_steps[1]["payload"]["form"] == "" + assert form_steps[2]["payload"]["view"] == "structure" for concrete_value in mcp_selector_chain_smoke.FORBIDDEN_CONCRETE_SELECTOR_VALUES: assert concrete_value not in examples_json @@ -14963,6 +18092,59 @@ def test_metadata_write_capabilities_reports_saved_state_rule() -> None: 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"] + assert [step["area"] for step in result["workflow_priority"][:3]] == [ + "bsl_modules", + "report_scd", + "templates", + ] + assert result["write_capabilities"]["report_scd"]["agent_method"] == "scd.patch" + assert [item["key"] for item in result["registered_handlers"]] == [ + "module", "form", "object_property", "object_member", "scheduled_job_schedule" + ] + + +def test_adapter_health_reports_saved_state_write_and_activation_boundary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + adapter_server, + "sql_config_for_base", + lambda base_id: ( + {"server": "sql.example", "database": base_id, "user": "adapter"}, + None, + ), + ) + + result = adapter_server.AdapterState().health(base_id="upo_test") + + assert result["status"] == "ok" + assert "saved-state-write" in result["capabilities"] + assert "active-saved-compare" in result["capabilities"] + assert result["features"]["saved_state_write"]["available"] is True + assert result["features"]["saved_state_write"]["agent_method"] == "code.write" + assert result["features"]["active_saved_compare"]["module_text_sha1"] is True + assert result["features"]["configuration_activation"] == { + "available": True, + "status": "debug_acceptance_available", + "methods": [ + "configuration.activation.status", + "configuration.activation.plan", + "configuration.activation.request", + "configuration.activation.request.status", + "configuration.activation.request.cancel", + "configuration.activation.audit", + "configuration.activation.capabilities", + "configuration.activation.bridge.probe", + "configuration.activation.execute", + "configuration.activation.verify", + ], + "debug_acceptance_available": True, + "execution_available": False, + "reason": "designer_activation_bridge_not_implemented", + "saved_state_only": True, + "read_only": True, + "state_store": "local_sqlite", + } def test_adapter_help_describes_selector_aliases_for_all_object_methods() -> None: @@ -16465,6 +19647,17 @@ def test_form_section_records_decode_saved_data_and_current_row_properties() -> assert attribute_props["SavedData"]["parameter_index"] == 11 +def test_form_section_semantic_title_uses_decoded_title_path_when_direct_parameter_is_empty() -> None: + semantic = section_record_semantic_properties( + {"category": "Command", "name": "Команда", "title": "Показать продажи"}, + [{"index": 3, "value": ""}], + ) + properties = {prop["name"]: prop for group in semantic["groups"].values() for prop in group} + + assert properties["Заголовок"]["value"] == "Показать продажи" + assert properties["Заголовок"]["source"] == "form_payload_title_path" + + def test_form_table_additions_expose_structured_addition_source() -> None: records = [ {"name": "ТЗ", "marker": "73", "type_name": "Таблица формы", "path": "1.25.10", "semantic": {"groups": {}}}, @@ -19814,6 +23007,87 @@ def test_form_element_write_rejects_active_config_table() -> None: assert result["argument"] == "table" +def test_form_element_write_uses_extension_saved_state_for_name_first_selector(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_resolve(payload: dict[str, Any]) -> dict[str, Any]: + seen["table"] = payload["table"] + return {"status": "not_found", "base_id": payload["base_id"], "diagnostics": {"message": "stop"}} + + monkeypatch.setattr(adapter_server, "metadata_form_write_target_resolve", fake_resolve) + result = adapter_server.metadata_form_element_write( + { + "base_id": "upo_test", + "extension": "фс_Отчеты1", + "ref": "Report.фс_ПродовольственнаяКорзина", + "form": "ФормаОтчетаПродкорзина", + "element": "КомпоновщикНастроекПользовательскиеНастройки", + "property": "Группировка", + "value": "Horizontal", + "allow_saved_state_write": True, + } + ) + + assert result["status"] == "not_found" + assert seen["table"] == "ConfigCASSave" + + +def test_repository_layer_connection_set_resolves_extension_name_before_saving(monkeypatch: pytest.MonkeyPatch) -> None: + extension_guid = "8e02accd-8a34-11f1-8294-005056b0d483" + saved: dict[str, Any] = {} + monkeypatch.setattr( + adapter_server, + "extension_filter_to_guid", + lambda base_id, extension, method: (extension_guid, None), + ) + monkeypatch.setattr(adapter_server, "sql_admin_load", lambda: {"upo_test": {"server": "sql", "database": "upo_test", "user": "sa"}}) + monkeypatch.setattr(adapter_server, "sql_admin_save", lambda config: saved.setdefault("config", config)) + monkeypatch.setattr( + adapter_server.repository_control, + "status", + lambda payload: {"status": "manual_confirmation_required", "repository": {"mode": "manual"}}, + ) + + result = adapter_server.repository_layer_connection_set( + { + "base_id": "upo_test", + "extension": "фс_Отчеты1", + "connection_state": "configured", + "confirm_repository_connection_change": True, + } + ) + + layer = saved["config"]["upo_test"]["development_layers"][f"extension:{extension_guid}"] + assert result["status"] == "saved" + assert result["layer_id"] == f"extension:{extension_guid}" + assert layer["repository"]["mode"] == "manual" + assert layer["repository"]["connection_state"] == "configured" + + +def test_repository_layer_connection_plan_is_name_first_and_read_only(monkeypatch: pytest.MonkeyPatch) -> None: + extension_guid = "8e02accd-8a34-11f1-8294-005056b0d483" + monkeypatch.setattr(adapter_server, "extension_filter_to_guid", lambda base_id, extension, method: (extension_guid, None)) + monkeypatch.setattr( + adapter_server.repository_control, + "development_layer_config", + lambda base_id, layer_id: (None, {"status": "layer_not_configured"}), + ) + + result = adapter_server.repository_layer_connection_plan( + {"base_id": "upo_test", "extension": "фс_Отчеты1", "connection_state": "configured"} + ) + + assert result["status"] == "needs_configuration" + assert result["target"] == {"kind": "extension", "extension": "фс_Отчеты1"} + assert result["apply"]["payload"] == { + "base_id": "upo_test", + "extension": "фс_Отчеты1", + "connection_state": "configured", + "confirm_repository_connection_change": True, + } + assert result["diagnostics"]["read_only"] is True + + 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" @@ -20817,6 +24091,7 @@ def test_metadata_write_routes_common_form_command_path_to_command_button_writer } monkeypatch.setattr(adapter_server, "metadata_form_command_button_write", fake_command_button_write) + monkeypatch.setattr(adapter_server, "resolve_write_gate_context", lambda payload: payload) result = adapter_server.metadata_write( { @@ -20845,6 +24120,7 @@ def test_metadata_write_routes_common_form_button_path_to_command_button_writer( return {"schema": "onec_form_command_button_write.v1", "status": "planned"} monkeypatch.setattr(adapter_server, "metadata_form_command_button_write", fake_command_button_write) + monkeypatch.setattr(adapter_server, "resolve_write_gate_context", lambda payload: payload) result = adapter_server.metadata_write( { @@ -21759,6 +25035,53 @@ def test_metadata_write_plan_resolves_public_module_selector_internally(monkeypa assert hint["payload"]["expected_sha1"] == "saved-payload-sha1" +def test_metadata_write_plan_normalizes_top_level_public_module_selector(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_search(payload: dict[str, Any]) -> dict[str, Any]: + seen.update(payload) + return { + "schema": "onec_saved_state_module_search.v1", + "status": "ok", + "modules": [{ + "form": {"name": "ФормаОтчетаПродкорзина"}, + "module": {"kind": "form_module", "name": "Модуль формы"}, + "payload": {"sha1": "saved-payload-sha1"}, + "streams": [{ + "module_ref": "ConfigCASSave:extension-guid__form-guid.0", + "form": {"name": "ФормаОтчетаПродкорзина"}, + "module": {"kind": "form_module", "name": "Модуль формы"}, + }], + }], + } + + monkeypatch.setattr(adapter_server, "metadata_saved_state_modules_search", fake_search) + + result = adapter_server.metadata_write_plan( + { + "base_id": "upo_test", + "target_kind": "module", + "ref": "Report.фс_ПродовольственнаяКорзина", + "form": "ФормаОтчетаПродкорзина", + "module": "Модуль формы", + "extension": "фс_Отчеты1", + "intent": { + "operation": "replace_unique_fragment", + "old": "СтарыйФрагмент", + "control_fragment": "СтарыйФрагмент", + "new": "НовыйФрагмент", + }, + "resolve_origin": False, + } + ) + + assert seen["ref"] == "Report.фс_ПродовольственнаяКорзина" + assert seen["extension"] == "фс_Отчеты1" + assert result["allowed"] is True + assert result["route"]["name_resolution"]["status"] == "resolved" + assert result["route"]["apply_payload_hint"]["payload"]["module_ref"] == "ConfigCASSave:extension-guid__form-guid.0" + + def test_metadata_write_plan_resolves_public_form_selector_internally(monkeypatch: pytest.MonkeyPatch) -> None: seen: dict[str, Any] = {} @@ -21814,8 +25137,9 @@ def test_metadata_write_plan_resolves_public_form_selector_internally(monkeypatc } hint = result["route"]["apply_payload_hint"] assert hint["ready_for_apply_method"] is True - assert hint["payload"]["table"] == "ConfigCASSave" - assert hint["payload"]["file_name"] == "extension-guid__form-guid.0" + assert "table" not in hint["payload"] + assert "file_name" not in hint["payload"] + assert hint["payload"]["extension"] == "test2" assert hint["payload"]["ref"] == "Catalog.test2" assert hint["payload"]["form"] == "t_Форма" assert hint["payload"]["command"] == "ЗаменаДомена" @@ -21922,6 +25246,51 @@ def test_write_gate_context_does_not_fall_back_to_base_for_unknown_extension( assert adapter_server.repository_control.development_layer_id(result) == "extension:unresolved" +def test_write_gate_context_resolves_concrete_saved_form_module_without_global_owner_scan( + monkeypatch: pytest.MonkeyPatch, +) -> None: + extension_guid = "fb26cf42-7609-11f1-828f-005056b0d483" + form_guid = "77494708-43ea-4956-ac3c-199cfb035ad2" + file_name = f"{extension_guid}__{form_guid}.0" + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_modules_search", + lambda payload: { + "status": "ok", + "modules": [ + { + "table": "ConfigCASSave", + "file_name": file_name, + "source": {"table": "ConfigCASSave", "file_name": file_name}, + "owner": {"kind": "Catalog", "name": "test2", "guid": "owner-guid"}, + "form": {"name": "t_Форма", "guid": form_guid}, + } + ], + }, + ) + monkeypatch.setattr( + adapter_server, + "repository_resolve_form_guid_sql", + lambda **_kwargs: pytest.fail("concrete saved form module must not start a global owner scan"), + ) + + result = adapter_server.resolve_write_gate_context( + { + "base_id": "upo_test", + "target": { + "kind": "module", + "module_ref": f"ConfigCASSave:{file_name}", + }, + "timeout_seconds": 30, + } + ) + + assert result["repository_object"] == "Catalog.test2.Form.t_Форма" + assert result["extension_guid"] == extension_guid + assert result["owner_resolution"]["source"] == "live_sql_saved_form_module" + assert result["development_layer_resolution"]["layer_id"] == f"extension:{extension_guid}" + + def test_write_gate_context_rejects_conflicting_extension_name_and_guid( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -22092,6 +25461,82 @@ def test_metadata_write_preflight_routes_public_extension_form_gates_to_extensio assert captured_layers == [expected_layer, expected_layer] +def test_metadata_write_preflight_explains_missing_exact_extension_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + extension_guid = "8e02accd-8a34-11f1-8294-005056b0d483" + monkeypatch.setattr( + adapter_server, + "extension_filter_to_guid", + lambda base_id, extension, method: (extension_guid, None), + ) + monkeypatch.setattr( + adapter_server, + "metadata_form_write_target_resolve", + lambda payload: { + "schema": "onec_form_write_target_resolution.v1", + "status": "ok", + "source": {"kind": "live_sql", "table": "ConfigCASSave", "file_name": f"{extension_guid}__form-guid.0"}, + "counts": {"matches": 1}, + }, + ) + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_diff", + lambda payload: {"status": "changed", "target": {"table": payload["table"], "file_name": payload["file_name"]}}, + ) + layer_id = f"extension:{extension_guid}" + monkeypatch.setattr( + adapter_server.repository_control, + "write_gate", + lambda payload: { + "status": "blocked_repository_configuration", + "allowed": False, + "layer_id": layer_id, + "problem": {"status": "layer_not_configured"}, + }, + ) + monkeypatch.setattr( + adapter_server, + "live_sql_support_gate", + lambda payload: {"status": "blocked_support_layer_unknown", "allowed": False, "layer_id": layer_id}, + ) + + result = adapter_server.metadata_write_preflight( + { + "base_id": "upo_test", + "target": { + "kind": "form", "extension": "фс_Отчеты1", "ref": "Report.фс_ПродовольственнаяКорзина", + "form": "ФормаОтчетаПродкорзина", "element": "КомпоновщикНастроекПользовательскиеНастройки", + }, + "edits": [{"property": "Группировка", "value": "Horizontal"}], + "resolve_origin": False, + } + ) + + assert result["status"] == "blocked_repository_configuration" + assert result["development_layer_configuration"] == { + "status": "required", + "error": "development_layer_not_configured", + "layer_id": layer_id, + "extension": "фс_Отчеты1", + "message": "This exact extension has no repository/support development-layer policy in the adapter configuration. The adapter will not substitute another extension policy.", + "next_action": "configure_exact_extension_development_layer", + } + assert result["development_layer_configuration_plan"]["status"] == "needs_configuration" + assert result["development_layer_configuration_plan"]["target"] == { + "kind": "extension", "extension": "фс_Отчеты1" + } + assert result["development_layer_configuration_plan"]["apply"]["requires_explicit_administrator_confirmation"] is True + assert result["support"] == { + "required": True, + "allowed": False, + "status": "deferred_until_repository_lock", + "layer_id": layer_id, + "reason": "repository_lock_required", + } + + def test_metadata_write_preflight_blocks_conflicting_extension_name_and_guid( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -22236,6 +25681,10 @@ def test_metadata_write_preflight_verifies_concrete_saved_state(monkeypatch: pyt assert result["saved_state"]["status"] == "changed" assert result["saved_state"]["freshness"]["status"] == "live_sql_verified" assert result["guards"]["rollback_available"] is True + assert result["write_context"]["module_ref"] == "ConfigCASSave:object-guid__module-guid.0#stream:4" + assert result["write_context"]["table"] == "ConfigCASSave" + assert result["write_context"]["file_name"] == "object-guid__module-guid.0" + assert result["write_context"]["stream_index"] == 4 def test_metadata_write_preflight_reports_prepare_needed(monkeypatch: pytest.MonkeyPatch) -> None: @@ -22288,7 +25737,7 @@ def test_metadata_write_preflight_blocks_effective_path_without_route() -> None: assert result["diagnostics"]["read_only"] is True -def test_metadata_write_plan_blocks_container_module_ref_without_stream() -> None: +def test_metadata_write_plan_routes_container_module_ref_to_code_write() -> None: result = adapter_server.metadata_write_plan( { "base_id": "upo_test", @@ -22303,13 +25752,9 @@ def test_metadata_write_plan_blocks_container_module_ref_without_stream() -> Non hint = result["route"]["apply_payload_hint"] 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 hint["method"] == "code.write" + assert hint["ready_for_apply_method"] is True + assert hint["routing"]["reason"] == "container_module_requires_agent_code_write" assert "#stream:" not in hint["payload"]["module_ref"] @@ -23250,7 +26695,7 @@ def test_form_embedded_module_apply_replaces_fragment_inside_routine_scope(monke assert "Процедура Other" in seen["new_text"] -def test_code_write_facade_defaults_to_save_first_and_hides_storage(monkeypatch: pytest.MonkeyPatch) -> None: +def test_code_write_apply_requires_exact_verified_target(monkeypatch: pytest.MonkeyPatch) -> None: seen: dict[str, Any] = {} def fake_metadata_write(payload: dict[str, Any]) -> dict[str, Any]: @@ -23275,6 +26720,7 @@ def test_code_write_facade_defaults_to_save_first_and_hides_storage(monkeypatch: result = adapter_server.code_write( { "base_id": "upo_test", + "execution_mode": "apply", "object_type": "CommonForm", "object_name": "t_Форма", "routine_name": "ЗаменаДомена", @@ -23282,25 +26728,31 @@ def test_code_write_facade_defaults_to_save_first_and_hides_storage(monkeypatch: } ) - assert result["schema"] == "onec_code_write.v1" - assert result["status"] == "applied" - assert result["operation"] == "routine_replace" - assert result["applied"] is True - assert result["write_mode"] == { - "target": "saved_state", - "activation_state": "not_activated", - "production_apply": False, - } - assert "metadata_write" not in result - assert seen["payload"]["mode"] == "apply" - assert seen["payload"]["allow_sql_saved_state_apply"] is True - assert seen["payload"]["allow_sql_saved_state_prepare"] is True - assert seen["payload"]["target"] == { - "kind": "module", - "object_type": "CommonForm", - "object_name": "t_Форма", - "routine_name": "ЗаменаДомена", - } + assert result["status"] == "invalid_argument" + assert result["argument"] == "write_target" + assert "payload" not in seen + + +def test_code_write_defaults_to_plan_not_apply(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + monkeypatch.setattr( + adapter_server, + "metadata_write", + lambda payload: (seen.update(payload) or {"status": "planned", "result": {"status": "planned", "applied": False}}), + ) + + result = adapter_server.code_write( + { + "base_id": "upo_test", + "ref": "Report.Тест", + "old": "Старое", + "new": "Новое", + } + ) + + assert result["status"] == "planned" + assert result["execution_mode"] == "plan" + assert seen["mode"] == "plan" def test_code_write_normalizes_public_owner_ref_before_routing(monkeypatch: pytest.MonkeyPatch) -> None: @@ -23387,10 +26839,15 @@ def test_code_write_records_write_history(tmp_path: Path, monkeypatch: pytest.Mo "code.write", { "base_id": "upo_test", + "write_target": { + "path": "Расширение.test2.ОбщиеФормы.t_Форма.МодульФормы", + "expected_origin": {"extension": "test2"}, + "expected_sha1": "a" * 40, + }, "object_type": "CommonForm", "object_name": "t_Форма", - "routine_name": "ЗаменаДомена", - "routine_text": "Процедура ЗаменаДомена(Команда)\nКонецПроцедуры\n", + "old": "Вертикальная", + "new": "Горизонтальная", }, ) @@ -23468,6 +26925,54 @@ def test_code_write_facade_exposes_ambiguous_fragment_reason(monkeypatch: pytest assert result["scope"] == {"kind": "routine", "routine_name": "ЗаменаДомена"} +def test_code_write_apply_does_not_discover_target_before_repository_gate(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_write", + lambda payload: { + "schema": "onec_metadata_write.v1", + "method": "metadata.write", + "status": "blocked", + "result": { + "status": "blocked", + "applied": False, + "apply_result": { + "status": "blocked", + "error": "repository_lock_required", + "diagnostics": {"message": "Capture the object before writing."}, + "next_call": { + "method": "repository.lock.request", + "params": {"base_id": payload["base_id"]}, + }, + }, + }, + }, + ) + + result = adapter_server.code_write( + { + "base_id": "upo_test", + "object_type": "CommonModule", + "object_name": "ТестовыйМодуль", + "module_text": "Процедура Тест()\nКонецПроцедуры\n", + } + ) + + assert result["status"] == "blocked" + assert result["error"] == "repository_lock_required" + + +def test_collect_backup_ids_ignores_untyped_operation_and_index_ids() -> None: + backup_id = "0123456789abcdef0123456789abcdef" + assert adapter_server.collect_backup_ids( + { + "backup": {"backup_id": backup_id}, + "operation_id": "11111111111111111111111111111111", + "index_event": {"event_id": "22222222222222222222222222222222"}, + } + ) == [backup_id] + + def test_code_write_facade_exposes_success_fragment_scope(monkeypatch: pytest.MonkeyPatch) -> None: seen: dict[str, Any] = {} @@ -24086,6 +27591,82 @@ def test_saved_state_backups_list_filters_without_payload_hex(tmp_path: Path, mo assert "payload_hex" not in result["backups"][0]["original"] +def test_saved_state_backups_prune_defaults_to_dry_run_and_preserves_latest( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + backup_dir = tmp_path / "backups" + backup_dir.mkdir() + now = time.time() + paths = [] + for index, age_days in enumerate((90, 60, 1), start=1): + path = backup_dir / f"backup-{index}.json" + path.write_text( + json.dumps( + { + "schema": "onec_storage_apply_backup.v1", + "backup_id": f"{index:032x}", + "created_at_utc": "2026-01-01T00:00:00Z", + "base_id": "upo_test", + "source": { + "table": "ConfigCASSave", + "file_name": "form.0", + }, + } + ), + encoding="utf-8", + ) + modified_at = now - age_days * 86400 + os.utime(path, (modified_at, modified_at)) + paths.append(path) + monkeypatch.setenv("ONEC_ADAPTER_BACKUP_DIR", str(backup_dir)) + monkeypatch.setattr( + adapter_server, + "metadata_write_history_referenced_backup_ids", + lambda base_id: {f"{1:032x}"} if base_id == "upo_test" else set(), + ) + + dry_run = adapter_server.storage_saved_state_backups_prune( + { + "base_id": "upo_test", + "table": "ConfigCASSave", + "older_than_days": 30, + "keep_latest": 1, + } + ) + + assert dry_run["status"] == "ok" + assert dry_run["dry_run"] is True + assert dry_run["counts"]["selected"] == 1 + assert dry_run["counts"]["deleted"] == 0 + assert dry_run["counts"]["protected_by_write_history"] == 1 + assert all(path.exists() for path in paths) + + confirmation = adapter_server.storage_saved_state_backups_prune( + { + "base_id": "upo_test", + "older_than_days": 30, + "keep_latest": 1, + "dry_run": False, + } + ) + assert confirmation["status"] == "confirmation_required" + + applied = adapter_server.storage_saved_state_backups_prune( + { + "base_id": "upo_test", + "older_than_days": 30, + "keep_latest": 1, + "dry_run": False, + "confirm_delete": True, + } + ) + assert applied["status"] == "ok" + assert applied["counts"]["deleted"] == 1 + assert sum(path.exists() for path in paths) == 2 + assert paths[0].exists() + + def test_form_write_target_resolve_finds_command_property(monkeypatch: pytest.MonkeyPatch) -> None: def fake_decode(payload: dict[str, Any]) -> dict[str, Any]: return { @@ -24377,6 +27958,59 @@ def test_form_element_write_apply_and_verify_does_not_rollback(monkeypatch: pyte assert result["applied"] is True +def test_form_element_write_apply_and_rollback_preserves_failed_apply_status(monkeypatch: pytest.MonkeyPatch) -> None: + proposal = {"status": "accepted_for_review", "source": {"table": "ConfigSave", "file_name": "form-guid.0"}, "encoded": {"payload_hex": "00"}} + monkeypatch.setattr(adapter_server, "metadata_form_element_write", lambda payload: proposal) + monkeypatch.setattr(adapter_server, "storage_saved_state_apply_proposal", lambda payload: { + "status": "blocked", "applied": False, "diagnostics": {"message": "apply gate rejected"}, + }) + + result = adapter_server.metadata_form_element_write_apply({ + "base_id": "upo_test", "execution_mode": "apply_and_rollback", + "allow_sql_saved_state_apply": True, "allow_sql_saved_state_rollback": True, + "table": "ConfigSave", "file_name": "form-guid.0", + "edits": [{"property": "title", "value": "Новый"}], + }) + + assert result["status"] == "blocked" + assert result["applied"] is False + assert result["diagnostics"] == {"message": "apply gate rejected"} + + +def test_form_element_write_apply_forwards_resolved_extension_context(monkeypatch: pytest.MonkeyPatch) -> None: + proposal = { + "status": "accepted_for_review", + "source": {"table": "ConfigCASSave", "file_name": "extension-guid__form-guid.0"}, + "encoded": {"payload_hex": "00"}, + } + seen: dict[str, Any] = {} + + def fake_context(payload: dict[str, Any]) -> dict[str, Any]: + result = dict(payload) + result["extension_guid"] = "a" * 32 + result["development_layer_resolution"] = {"status": "resolved", "layer_id": f"extension:{'a' * 32}"} + return result + + monkeypatch.setattr(adapter_server, "resolve_write_gate_context", fake_context) + monkeypatch.setattr(adapter_server, "repository_apply_gate", lambda *args: None) + monkeypatch.setattr(adapter_server, "metadata_form_element_write", lambda payload: proposal) + def fake_apply(payload: dict[str, Any]) -> dict[str, Any]: + seen["apply"] = payload + return {"status": "applied", "applied": True} + + monkeypatch.setattr(adapter_server, "storage_saved_state_apply_proposal", fake_apply) + + adapter_server.metadata_form_element_write_apply({ + "base_id": "upo_test", "extension": "test2", "execution_mode": "apply", + "allow_sql_saved_state_apply": True, + "edits": [{"property": "title", "value": "Новый"}], + }) + + assert seen["apply"]["extension_guid"] == "a" * 32 + assert seen["apply"]["development_layer_resolution"]["layer_id"] == f"extension:{'a' * 32}" + assert seen["apply"]["module_ref"] == "ConfigCASSave:extension-guid__form-guid.0#form_module" + + def test_form_element_write_apply_response_hides_payload_hex(monkeypatch: pytest.MonkeyPatch) -> None: proposal = { "status": "accepted_for_review", @@ -24768,6 +28402,7 @@ def test_metadata_module_write_apply_plans_stream_edit(monkeypatch: pytest.Monke } monkeypatch.setattr(adapter_server, "changes_propose", fake_changes_propose) + monkeypatch.setattr(adapter_server, "metadata_module_write_stream_codec_guard", lambda **kwargs: None) result = adapter_server.metadata_module_write_apply( { @@ -24818,6 +28453,8 @@ def test_metadata_module_write_apply_applies_and_rolls_back(monkeypatch: pytest. monkeypatch.setattr(adapter_server, "changes_propose", fake_changes_propose) monkeypatch.setattr(adapter_server, "metadata_write_apply_plan_gate", lambda *args, **kwargs: ({"allowed": True}, None)) + monkeypatch.setattr(adapter_server, "repository_apply_gate", lambda *args, **kwargs: None) + monkeypatch.setattr(adapter_server, "metadata_module_write_stream_codec_guard", lambda **kwargs: None) monkeypatch.setattr(adapter_server, "storage_saved_state_apply_proposal", fake_apply) monkeypatch.setattr(adapter_server, "storage_saved_state_rollback", fake_rollback) @@ -24831,6 +28468,9 @@ def test_metadata_module_write_apply_applies_and_rolls_back(monkeypatch: pytest. "allow_sql_saved_state_rollback": True, "lock_session_id": "rlock-test", "repository_object": "Справочник.Тест", + "ref": "Catalog.Тест", + "kind": "Catalog", + "name": "Тест", "old": "a", "new": "b", } @@ -24840,9 +28480,13 @@ def test_metadata_module_write_apply_applies_and_rolls_back(monkeypatch: pytest. assert seen["apply"]["proposal"] is proposal assert seen["apply"]["lock_session_id"] == "rlock-test" assert seen["apply"]["repository_object"] == "Справочник.Тест" + assert seen["apply"]["ref"] == "Catalog.Тест" + assert seen["apply"]["kind"] == "Catalog" + assert seen["apply"]["name"] == "Тест" assert seen["rollback"]["backup_id"] == "c" * 32 assert seen["rollback"]["lock_session_id"] == "rlock-test" assert seen["rollback"]["repository_object"] == "Справочник.Тест" + assert seen["rollback"]["ref"] == "Catalog.Тест" assert result["proposal"]["encoded"] == {"sha1": "new"} assert result["status"] == "verified_and_rolled_back" assert result["applied"] is True @@ -24862,6 +28506,7 @@ def test_metadata_module_write_apply_accepts_flat_routine_edit(monkeypatch: pyte } monkeypatch.setattr(adapter_server, "changes_propose", fake_changes_propose) + monkeypatch.setattr(adapter_server, "metadata_module_write_stream_codec_guard", lambda **kwargs: None) result = adapter_server.metadata_module_write_apply( { @@ -25077,7 +28722,7 @@ def test_saved_state_modules_search_returns_form_embedded_module_container(monke assert module["form"]["name"] == "t_Форма" assert module["module"] == {"kind": "form_module", "name": "Модуль формы"} stream = module["streams"][0] - assert stream["module_ref"] == "ConfigCASSave:extension-guid__form-guid.0" + assert stream["module_ref"] == "ConfigCASSave:extension-guid__form-guid.0#form_module" assert stream["module_path"] == "2" assert stream["form"]["name"] == "t_Форма" assert stream["module"] == {"kind": "form_module", "name": "Модуль формы"} @@ -25085,7 +28730,7 @@ def test_saved_state_modules_search_returns_form_embedded_module_container(monke assert stream["match"]["in_text"] is True write_target = stream["write_plan_target"] assert write_target["kind"] == "module" - assert write_target["module_ref"] == "ConfigCASSave:extension-guid__form-guid.0" + assert write_target["module_ref"] == "ConfigCASSave:extension-guid__form-guid.0#form_module" assert "#stream:" not in write_target["module_ref"] assert write_target["file_name"] == file_name assert write_target["module_path"] == "2" @@ -25513,7 +29158,7 @@ def test_saved_state_modules_search_resolves_common_form_name_to_saved_file(monk assert result["owner_resolution"]["status"] == "resolved" assert result["owner_resolution"]["method"] == "metadata.saved_state.forms.search" assert result["owner_resolution"]["file_names"] == [selected_file] - assert seen["read_files"] == [selected_file] + assert seen["read_files"] == [selected_file, selected_file] assert result["counts"]["modules"] == 1 assert result["modules"][0]["file_name"] == selected_file assert result["modules"][0]["payload"]["role"] == "form_embedded_module_payload" @@ -25553,8 +29198,169 @@ def test_saved_state_prepare_plans_active_to_save_copy(monkeypatch: pytest.Monke assert result["ready_to_copy"] is True assert result["source"]["table"] == "ConfigCAS" assert result["target"]["table"] == "ConfigCASSave" - assert result["counts"] == {"file_names": 1, "source_rows": 1, "target_rows": 0} + assert result["counts"] == {"file_names": 1, "copy_file_names": 1, "source_rows": 1, "copy_source_rows": 1, "target_rows": 0} assert result["write_mode"]["sql_write_performed"] is False + assert [stage["name"] for stage in result["stages"]] == [ + "resolve_source", + "inspect_active_source", + "inspect_saved_target", + ] + assert all(stage["status"] == "ok" for stage in result["stages"]) + + +@pytest.mark.parametrize("module_suffix", [".0", ".2"]) +def test_extension_prepare_route_maps_proven_root_to_canonical_configinfo( + monkeypatch: pytest.MonkeyPatch, + module_suffix: str, +) -> None: + """A proven root map preserves the resolved BSL module ordinal.""" + source_root = "a" * 40 + source_descriptor = "c" * 40 + source_module = "b" * 40 + cache_calls = 0 + refreshed: list[dict[str, Any]] = [] + monkeypatch.setattr(adapter_server, "sql_config_for_base", lambda _base_id: ({"database": "upo_test"}, None)) + def fake_cache_lookup(*_args: Any, **_kwargs: Any) -> list[dict[str, Any]]: + nonlocal cache_calls + cache_calls += 1 + return [] if cache_calls == 1 else [{"id": 1, "extension_name": "test-extension", "name": "TestReport"}] + + monkeypatch.setattr(adapter_server, "extension_route_cache_lookup", fake_cache_lookup) + monkeypatch.setattr( + adapter_server, + "extension_objects_find", + lambda payload: refreshed.append(payload) or {"status": "ok", "objects": []}, + ) + monkeypatch.setattr( + adapter_server, + "extension_route_cache_row_to_match", + lambda *_args, **_kwargs: { + "kind": "Report", + "guid": "e064659b-6bf8-4162-8c3d-ae186c151ff1", + "name": "TestReport", + "origin": {"extension": {"name": "test-extension", "guid": "8e02accd-8a34-11f1-8294-005056b0d483"}}, + "route": {"file_name": source_root, "manifest_entry": {"root_cas_key": source_root}}, + "manifest_entries": [ + {"cas_key": source_descriptor, "suffix": "", "root_cas_key": source_root}, + {"cas_key": source_module, "suffix": module_suffix, "root_cas_key": source_root}, + ], + }, + ) + monkeypatch.setattr(adapter_server, "metadata_object_forms", lambda *_args, **_kwargs: {"forms": []}) + + file_names, object_card, error = adapter_server.saved_state_prepare_file_names( + { + "base_id": "upo_test", + "extension": "test-extension", + "ref": "Report.TestReport", + }, + "upo_test", + "ConfigCAS", + 30, + ) + + assert error is None + assert file_names == sorted([source_root, source_descriptor, source_module]) + assert object_card is not None + assert object_card["saved_state_protocol"] == "canonical_configinfo_pair" + assert object_card["saved_state_name_map"] == { + source_root: "8e02accd-8a34-11f1-8294-005056b0d483__configinfo", + source_descriptor: "8e02accd-8a34-11f1-8294-005056b0d483__e064659b-6bf8-4162-8c3d-ae186c151ff1", + source_module: f"8e02accd-8a34-11f1-8294-005056b0d483__e064659b-6bf8-4162-8c3d-ae186c151ff1{module_suffix}", + } + assert len(refreshed) == 1 + assert refreshed[0]["refresh_cache"] is False + + +def test_extension_prepare_blocks_hash_key_copy_without_proven_working_copy_codec(monkeypatch: pytest.MonkeyPatch) -> None: + """SQL-readable hash copies must not be advertised as Configurator-ready.""" + monkeypatch.setattr(adapter_server, "repository_apply_gate", lambda *_args: None) + monkeypatch.setattr( + adapter_server, + "saved_state_prepare_file_names", + lambda *_args: ( + ["a" * 40, "b" * 40], + {"saved_state_protocol": "source_key_copy", "saved_state_name_map": {}}, + None, + ), + ) + + result = adapter_server.metadata_saved_state_prepare( + { + "base_id": "upo_test", + "extension": "test-extension", + "layer": "extension_saved_state", + "ref": "Report.TestReport", + "mode": "plan", + "include_storage": True, + } + ) + + assert result["status"] == "extension_saved_state_prepare_protocol_unproven" + assert result["applied"] is False + assert result["saved_state_protocol"] == "source_key_copy" + + +def test_saved_state_prepare_verify_merge_preserves_existing_extension_configinfo(monkeypatch: pytest.MonkeyPatch) -> None: + """A pending ConfigInfo map is preserved, not compared to active ConfigCAS.""" + source_rows = [ + {"FileName": "active-root", "PartNo": 0, "DataSize": 10, "BinaryBytes": 10, "BinarySHA1": "A" * 40}, + {"FileName": "active-descriptor", "PartNo": 0, "DataSize": 11, "BinaryBytes": 11, "BinarySHA1": "B" * 40}, + {"FileName": "active-module", "PartNo": 0, "DataSize": 12, "BinaryBytes": 12, "BinarySHA1": "C" * 40}, + ] + existing_configinfo = {"FileName": "ext__configinfo", "PartNo": 0, "DataSize": 13, "BinaryBytes": 13, "BinarySHA1": "D" * 40} + copied_rows = [ + {"FileName": "ext__object", "PartNo": 0, "DataSize": 11, "BinaryBytes": 11, "BinarySHA1": "B" * 40}, + {"FileName": "ext__object.0", "PartNo": 0, "DataSize": 12, "BinaryBytes": 12, "BinarySHA1": "C" * 40}, + ] + target_reads = 0 + + monkeypatch.setattr(adapter_server, "repository_apply_gate", lambda *_args: None) + monkeypatch.setattr( + adapter_server, + "saved_state_prepare_file_names", + lambda *_args: ( + ["active-root", "active-descriptor", "active-module"], + { + "saved_state_name_map": { + "active-root": "ext__configinfo", + "active-descriptor": "ext__object", + "active-module": "ext__object.0", + }, + }, + None, + ), + ) + + def fake_rows(_base_id: str, table: str, _file_names: list[str], *, timeout_seconds: int = 30): + nonlocal target_reads + if table == "ConfigCAS": + return source_rows, {"database": "upo_test"}, None + target_reads += 1 + rows = [existing_configinfo] if target_reads == 1 else [existing_configinfo, *copied_rows] + return rows, {"database": "upo_test"}, None + + monkeypatch.setattr(adapter_server, "saved_state_copy_row_details", fake_rows) + monkeypatch.setattr( + adapter_server, + "apply_saved_state_prepare_copy", + lambda *_args, **_kwargs: {"status": "applied", "applied": True, "counts": {"inserted_rows": 2}}, + ) + + result = adapter_server.metadata_saved_state_prepare( + { + "base_id": "upo_test", + "extension": "test2", + "layer": "extension_saved_state", + "ref": "CommonForm.t_Форма", + "mode": "apply_and_verify", + "allow_sql_saved_state_prepare": True, + "include_storage": True, + } + ) + + assert result["status"] == "verified" + assert result["verification"] == {"status": "ok", "expected_rows": 3, "actual_rows": 3, "mismatched": []} def test_saved_state_prepare_default_is_name_first_and_hides_storage(monkeypatch: pytest.MonkeyPatch) -> None: @@ -25614,6 +29420,772 @@ def test_saved_state_prepare_default_is_name_first_and_hides_storage(monkeypatch assert storage_marker not in serialized +def test_saved_state_ensure_plans_missing_copy_without_configurator(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_prepare(payload: dict[str, Any]) -> dict[str, Any]: + seen.update(payload) + return {"status": "plan_ready", "base_id": "upo_test", "applied": False} + + monkeypatch.setattr(adapter_server, "metadata_saved_state_prepare", fake_prepare) + + result = adapter_server.metadata_saved_state_ensure( + {"base_id": "upo_test", "extension": "test2", "ref": "Report.Тест"} + ) + + assert seen["merge_missing"] is True + assert result["status"] == "plan_ready" + assert result["saved_state"] == { + "ready": False, + "created": False, + "next_action": "apply_with_allow_sql_saved_state_prepare", + "manual_configurator_required": False, + "manual_configurator_note": "A missing saved-state copy is created by the adapter; do not ask the user to create it in Configurator.", + } + + +def test_saved_state_ensure_is_ready_when_copy_already_exists(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_prepare", + lambda _payload: {"status": "already_prepared", "base_id": "upo_test", "applied": False}, + ) + + result = adapter_server.metadata_saved_state_ensure({"base_id": "upo_test", "ref": "Catalog.Номенклатура"}) + + assert result["status"] == "ready" + assert result["saved_state"]["ready"] is True + assert result["saved_state"]["next_action"] == "write_saved_state" + + +def test_saved_state_prepare_exposes_opaque_rollback_receipt_without_storage_details() -> None: + receipt_id = "a" * 32 + + result = adapter_server.public_saved_state_prepare_result( + { + "status": "verified", + "applied": True, + "apply_result": { + "counts": {"inserted_rows": 1}, + "prepare_receipt": { + "receipt_id": receipt_id, + "path": "C:/private/prepare-receipts/receipt.json", + "rows": 1, + }, + }, + }, + base_id="upo_test", + target_table="ConfigSave", + object_card={"kind": "Catalog", "name": "Номенклатура"}, + ) + + assert result["prepare_receipt_id"] == receipt_id + assert "prepare-receipts" not in json.dumps(result, ensure_ascii=False) + + +def test_code_write_accepts_exact_public_ref_for_apply_without_opaque_write_target() -> None: + result = adapter_server.code_write_strict_target_error( + { + "ref": "Report.ТестовыйОтчет", + "old": "СтарыйФрагмент", + "new": "НовыйФрагмент", + "occurrences": 1, + }, + require_explicit_target=True, + ) + + assert result is None + + +def test_code_write_apply_routes_exact_public_ref_without_opaque_write_target(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": "verified", + "execution_mode": "apply_and_verify", + "target_kind": "module", + "base_id": "upo_test", + "routed_method": "metadata.module.write_apply", + "result": { + "status": "verified", + "applied": True, + "verification": {"status": "ok", "new_occurrences": 1}, + "apply_result": {"backup": {"backup_id": "b" * 32}}, + }, + } + + monkeypatch.setattr(adapter_server, "metadata_write", fake_metadata_write) + + result = adapter_server.code_write( + { + "base_id": "upo_test", + "ref": "Catalog.Контрагенты", + "old": "СтарыйФрагмент", + "new": "НовыйФрагмент", + "execution_mode": "apply_and_verify", + "allow_sql_saved_state_apply": True, + } + ) + + assert result["status"] == "verified" + assert result["applied"] is True + assert seen["payload"]["target"] == { + "kind": "module", + "object_type": "Catalog", + "object_name": "Контрагенты", + } + assert result["verification"] == {"status": "ok", "new_occurrences": 1} + assert result["rollback"]["method"] == "metadata.write.rollback" + assert result["rollback"]["params"]["backup_id"] == "b" * 32 + + +def test_code_write_public_ref_apply_auto_prepares_missing_saved_state(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_metadata_write(payload: dict[str, Any]) -> dict[str, Any]: + seen["payload"] = payload + return {"status": "needs_prepare", "result": {"status": "needs_prepare", "applied": False}} + + monkeypatch.setattr(adapter_server, "metadata_write", fake_metadata_write) + + adapter_server.code_write( + { + "base_id": "upo_test", + "ref": "Catalog.Контрагенты", + "old": "СтарыйФрагмент", + "new": "НовыйФрагмент", + "execution_mode": "apply_and_verify", + "allow_sql_saved_state_apply": True, + } + ) + + assert seen["payload"]["auto_prepare_saved_state"] is True + assert seen["payload"]["allow_sql_saved_state_prepare"] is True + + +def test_code_write_public_retry_already_applied_does_not_prepare_or_write(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[dict[str, Any]] = [] + + monkeypatch.setattr( + adapter_server, + "code_read", + lambda _payload: { + "status": "ok", + "text": "Процедура Печать()\nНоваяСтрока.Выбран = ЗначениеЗаполнено(Количество);\nКонецПроцедуры", + }, + ) + monkeypatch.setattr(adapter_server, "metadata_write", lambda payload: calls.append(payload) or {"status": "error"}) + + result = adapter_server.code_write( + { + "base_id": "upo_test", + "ref": "Report.ПечатьЦенников", + "extension": "фс_Отчеты", + "routine_name": "Печать", + "old": "НоваяСтрока.Выбран = Истина;", + "new": "НоваяСтрока.Выбран = ЗначениеЗаполнено(Количество);", + "execution_mode": "apply_and_verify", + "allow_sql_saved_state_apply": True, + } + ) + + assert result["status"] == "already_applied" + assert result["applied"] is False + assert result["counts"] == {"occurrences": 0, "new_occurrences": 1} + assert calls == [] + + +def test_code_write_apply_and_rollback_cleans_auto_prepared_rows(monkeypatch: pytest.MonkeyPatch) -> None: + receipt_id = "a" * 32 + seen: dict[str, Any] = {} + + monkeypatch.setattr( + adapter_server, + "metadata_write", + lambda _payload: { + "status": "verified_and_rolled_back", + "base_id": "upo_test", + "routed_method": "metadata.module.write_apply", + "prepare_receipt_id": receipt_id, + "result": {"applied": True, "apply_result": {"applied": True, "readback": {"verified": True}}}, + }, + ) + + def fake_cleanup(base_id: str, received_receipt_id: str, *, timeout_seconds: int) -> dict[str, Any]: + seen.update({"base_id": base_id, "receipt_id": received_receipt_id, "timeout_seconds": timeout_seconds}) + return {"status": "rolled_back", "applied": True, "counts": {"deleted_rows": 4}} + + monkeypatch.setattr(adapter_server, "rollback_saved_state_prepare_receipt", fake_cleanup) + + result = adapter_server.code_write( + { + "base_id": "upo_test", + "ref": "Report.АвтоматическиеСкидки", + "module_ordinal": 1, + "old": "старый", + "new": "новый", + "mode": "apply_and_rollback", + "timeout_seconds": 45, + } + ) + + assert result["status"] == "verified_and_rolled_back" + assert result["prepare_receipt_id"] == receipt_id + assert result["prepare_cleanup"] == {"status": "rolled_back", "applied": True, "counts": {"deleted_rows": 4}} + assert seen == {"base_id": "upo_test", "receipt_id": receipt_id, "timeout_seconds": 45} + + +def test_code_write_hides_internal_saved_state_route_hints(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_write", + lambda _payload: { + "status": "not_found", + "result": { + "error": "module_target_not_resolved", + "diagnostics": {"message": "Pass module_ref or stream_index."}, + "next_resolution": {"method": "metadata.saved_state.prepare", "params": {"file_name": "internal.0"}}, + }, + }, + ) + + result = adapter_server.code_write( + { + "base_id": "upo_test", + "ref": "Report.Тест", + "module_ordinal": 1, + "old": "старый", + "new": "новый", + "mode": "apply_and_verify", + } + ) + + assert result["error"] == "public_write_route_unresolved" + assert result["diagnostics"]["next_action"] == "adapter_development_required" + assert result["route_resolution"] == { + "error": "module_target_not_resolved", + } + assert "next_call" not in result + assert "module_ref" not in str(result) + assert "stream_index" not in str(result) + + +def test_adapter_audit_result_summary_keeps_public_route_diagnostics_only() -> None: + summary = adapter_server.adapter_audit_result_summary( + { + "status": "not_found", + "error": "public_write_route_unresolved", + "timings_ms": {"metadata_write": 152000}, + "route_resolution": { + "method": "metadata.object.modules", + "status": "not_found", + "error": "module_target_not_resolved", + "candidates": 0, + }, + "diagnostics": {"next_action": "adapter_development_required", "message": "private text"}, + "module_ref": "ConfigCASSave:private.0#stream:1", + } + ) + + assert summary["route_resolution"]["error"] == "module_target_not_resolved" + assert summary["diagnostics"] == {"next_action": "adapter_development_required"} + assert "module_ref" not in str(summary) + assert "private text" not in str(summary) + + +def test_public_module_role_does_not_invent_command_from_suffix() -> None: + role = adapter_server.public_module_role(owner_kind="Report", suffix="2", ordinal=2) + + assert role["kind"] == "bsl_module" + assert role["role_status"] == "unconfirmed" + assert role["configurator_path_status"] == "unconfirmed" + + +def test_code_search_active_hit_pins_runtime_view_in_read_selector(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "search_modules", + lambda _payload: { + "status": "ok", + "source": {"kind": "live_metadata"}, + "matches": [ + { + "snippet": {"text": "Фрагмент", "offset": 0}, + "activation_state": "active", + "owner": {"kind": "Report", "name": "Тест"}, + "module": {"kind": "bsl_module", "module_ordinal": 2}, + "origin": {"source": "extension"}, + "read_selector": {"base_id": "upo_test", "kind": "Report", "name": "Тест", "module_ordinal": 2}, + } + ], + "counts": {"matches": 1}, + }, + ) + + result = adapter_server.code_search({"base_id": "upo_test", "ref": "Report.Тест", "query": "Фрагмент"}) + + assert result["items"][0]["read_selector"]["configuration_view"] == "runtime_applied" + + +def test_modules_search_enforces_total_timeout_before_extension_owner_scan(monkeypatch: pytest.MonkeyPatch) -> None: + clock = iter((0.0, 0.0, 2.0, 2.0)) + monkeypatch.setattr(adapter_server.time, "monotonic", lambda: next(clock)) + monkeypatch.setattr( + adapter_server, + "extension_filter_to_guid", + lambda *_args, **_kwargs: ("11111111-1111-1111-1111-111111111111", None), + ) + monkeypatch.setattr(adapter_server, "extension_definition_guid_sources", lambda *_args, **_kwargs: ({}, None)) + monkeypatch.setattr( + adapter_server, + "extension_objects_find", + lambda *_args, **_kwargs: pytest.fail("owner scan must not start after the total deadline"), + ) + + result = adapter_server.search_modules( + {"base_id": "upo_test", "extension": "ТестовоеРасширение", "query": "Процедура", "timeout_seconds": 1} + ) + + assert result["status"] == "partial" + assert result["counts"]["time_budget_exhausted"] is True + assert result["diagnostics"]["stage"] == "extension_objects_find" + + +def test_help_methods_registry_does_not_depend_on_object_decode_locals() -> None: + result = adapter_server.call_method("help.methods", {}) + + assert result["schema"] == "onec_adapter_methods.v1" + assert any(item.get("name") == "code.write" for item in result["methods"]) + + +def test_saved_state_object_module_search_resolves_hash_keyed_extension_stream(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_object_modules", + lambda _payload: { + "status": "ok", + "object": {"guid": "a" * 36, "kind": "Report", "name": "Тест", "synonym": "Тест"}, + "modules": [ + { + "module_id": "ConfigCAS:hash-key#stream:4", + "kind": "object_module", + "qualified_name": "Тест.Модуль объекта", + } + ], + }, + ) + monkeypatch.setattr( + adapter_server, + "read_module", + lambda _payload: {"status": "ok", "text": "Процедура Проверка()\nКонецПроцедуры", "content_sha1": "b" * 40}, + ) + + rows = adapter_server.saved_state_object_module_rows_from_public_owner( + base_id="upo_test", + ref="Report.Тест", + extension="ТестРасширение", + query="Проверка", + timeout_seconds=30, + ) + + assert len(rows) == 1 + assert rows[0]["owner"]["name"] == "Тест" + assert rows[0]["streams"][0]["module_ref"] == "ConfigCASSave:hash-key#stream:4" + assert rows[0]["streams"][0]["match"]["in_text"] is True + assert rows[0]["streams"][0]["expected_text_sha1"] == "b" * 40 + + +def test_public_extension_object_module_resolver_maps_active_hash_to_canonical_saved_name(monkeypatch: pytest.MonkeyPatch) -> None: + """High-level writes must not ask a caller for ConfigCASSave coordinates.""" + active_hash = "a" * 40 + canonical_saved_name = "8e02accd-8a34-11f1-8294-005056b0d483__e064659b-6bf8-4162-8c3d-ae186c151ff1.0" + seen: dict[str, Any] = {} + monkeypatch.setattr( + adapter_server, + "metadata_object_modules", + lambda _payload: { + "status": "ok", + "modules": [ + {"kind": "object_module", "module_ordinal": 2, "module_id": f"ConfigCAS:{'c' * 40}#stream:4"}, + {"kind": "object_module", "module_ordinal": 1, "module_id": f"ConfigCAS:{active_hash}#stream:4"}, + ], + }, + ) + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_prepare", + lambda _payload: { + "status": "already_prepared", + "source_rows": [{"source_file_name": active_hash, "file_name": canonical_saved_name}], + }, + ) + + def fake_read_module(payload: dict[str, Any]) -> dict[str, Any]: + seen["module_ref"] = payload["module_ref"] + return { + "status": "ok", + "text": "Процедура Проверка()\nКонецПроцедуры", + "content_sha1": "b" * 40, + "write_preconditions": {"expected_text_sha1": "b" * 40}, + } + + monkeypatch.setattr(adapter_server, "read_module", fake_read_module) + target, resolution = adapter_server.metadata_write_resolve_public_object_module_saved_ref( + { + "base_id": "upo_test", + "extension": "фс_Отчеты1", + "ref": "Report.фс_ПродовольственнаяКорзина", + "module_ordinal": 1, + "old": "Проверка", + } + ) + + assert target is not None + assert target["module_ref"] == f"ConfigCASSave:{canonical_saved_name}#stream:4" + assert seen["module_ref"] == target["module_ref"] + assert resolution == {"method": "metadata.object.modules", "status": "resolved_public_object_module"} + + +def test_public_extension_object_module_resolver_does_not_use_old_fragment_as_route_key(monkeypatch: pytest.MonkeyPatch) -> None: + """A prior successful write removes old, but not the confirmed owner route.""" + active_hash = "a" * 40 + saved_name = "8e02accd-8a34-11f1-8294-005056b0d483__e064659b-6bf8-4162-8c3d-ae186c151ff1.2" + monkeypatch.setattr( + adapter_server, + "metadata_object_modules", + lambda _payload: {"status": "ok", "modules": [{"kind": "object_module", "module_ordinal": 2, "module_id": f"ConfigCAS:{active_hash}#stream:4"}]}, + ) + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_prepare", + lambda _payload: {"status": "already_prepared", "source_rows": [{"source_file_name": active_hash, "file_name": saved_name}]}, + ) + monkeypatch.setattr( + adapter_server, + "read_module", + lambda _payload: {"status": "ok", "text": "// replacement already applied", "content_sha1": "b" * 40}, + ) + + target, resolution = adapter_server.metadata_write_resolve_public_object_module_saved_ref( + {"base_id": "upo_test", "extension": "фс_Отчеты", "ref": "Report.Тест", "module_ordinal": 2, "old": "gone"} + ) + + assert target is not None + assert target["module_ref"] == f"ConfigCASSave:{saved_name}#stream:4" + assert resolution["status"] == "resolved_public_object_module" + + +def test_module_scope_fragment_reports_already_applied_for_exact_idempotent_retry(monkeypatch: pytest.MonkeyPatch) -> None: + routine = "Процедура Тест()\nНовыйФрагмент;\nКонецПроцедуры" + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", lambda *_args, **_kwargs: (b"unused", {}, None)) + monkeypatch.setattr( + "parser.cas_payload.classify_payload", + lambda *_args, **_kwargs: {"stream_blocks": [{"text": routine}]}, + ) + + result = adapter_server.metadata_module_write_scope_fragment_payload( + {"_force_fragment_replace": True, "routine_name": "Тест", "old": "СтарыйФрагмент;", "new": "НовыйФрагмент;"}, + base_id="upo_test", table="ConfigCASSave", file_name="module.2", stream_index=0, timeout_seconds=30, + method="metadata.module.write_apply", + ) + + assert result["status"] == "already_applied" + assert result["counts"] == {"occurrences": 0, "new_occurrences": 1} + + +def test_saved_state_modules_search_uses_object_name_for_extension_object_module(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "extension_filter_to_guid", lambda *_args, **_kwargs: ("a" * 36, None)) + monkeypatch.setattr(adapter_server, "metadata_saved_state_modules_owner_guid_from_selector", lambda *_args, **_kwargs: (None, {"status": "resolved"})) + monkeypatch.setattr( + adapter_server, + "metadata_object_modules", + lambda payload: { + "status": "ok", + "object": {"guid": "b" * 36, "kind": "Report", "name": "Тест"}, + "modules": [{"module_id": "ConfigCAS:hash-key#stream:2", "kind": "object_module", "qualified_name": "Тест.Модуль объекта"}], + }, + ) + monkeypatch.setattr( + adapter_server, + "read_module", + lambda _payload: {"status": "ok", "text": "Процедура Проверка()\nКонецПроцедуры", "content_sha1": "c" * 40}, + ) + monkeypatch.setattr(adapter_server, "storage_files_list", lambda _payload: (_ for _ in ()).throw(AssertionError("raw scan must not run"))) + + result = adapter_server.metadata_saved_state_modules_search( + { + "base_id": "upo_test", + "extension": "ТестРасширение", + "object_type": "Report", + "object_name": "Тест", + "module": "object_module", + "query": "Проверка", + "include_storage": True, + } + ) + + assert result["status"] == "ok" + stream = result["modules"][0]["streams"][0] + assert result["modules"][0]["owner"]["name"] == "Тест" + assert result["modules"][0]["module"]["kind"] == "object_module" + assert stream["module_ref"] == "ConfigCASSave:hash-key#stream:2" + assert stream["expected_text_sha1"] == "c" * 40 + + +def test_code_write_reclassifies_public_module_text_sha_as_text_precondition(monkeypatch: pytest.MonkeyPatch) -> None: + text_sha1 = "c" * 40 + monkeypatch.setattr( + adapter_server, + "metadata_write_resolve_public_object_module_saved_ref", + lambda _payload: ( + { + "module_ref": "ConfigCASSave:hash-key#stream:4", + "expected_text_sha1": text_sha1, + }, + {"method": "metadata.object.modules", "status": "resolved_public_object_module"}, + ), + ) + + resolved = adapter_server.metadata_write_resolve_module_target( + { + "base_id": "upo", + "ref": "Report.Тест", + "old": "СтарыйФрагмент", + "new": "НовыйФрагмент", + "expected_sha1": text_sha1, + }, + {}, + "plan", + ) + + assert isinstance(resolved, tuple) + write_payload, _resolution = resolved + assert write_payload["module_ref"] == "ConfigCASSave:hash-key#stream:4" + assert write_payload["expected_text_sha1"] == text_sha1 + assert "expected_sha1" not in write_payload + + +def test_module_write_plan_hint_preserves_text_sha_precondition() -> None: + text_sha1 = "d" * 40 + hint = adapter_server.metadata_write_plan_apply_payload_hint( + {"base_id": "upo_test", "expected_text_sha1": text_sha1, "old": "old", "new": "new"}, + {"kind": "module", "module_ref": "ConfigSave:test.0#stream:4"}, + {}, + path_resolution={}, + target_kind="module", + operation_class="replace", + concrete_reference="ConfigSave:test.0#stream:4", + ) + + assert hint is not None + assert hint["payload"]["expected_text_sha1"] == text_sha1 + + +def test_code_write_plan_returns_name_first_apply_hint(monkeypatch: pytest.MonkeyPatch) -> None: + text_sha1 = "e" * 40 + monkeypatch.setattr( + adapter_server, + "metadata_write", + lambda _payload: { + "status": "planned", + "routed_method": "metadata.module.write_apply", + "result": {"status": "planned", "applied": False}, + }, + ) + + result = adapter_server.code_write( + { + "base_id": "upo", + "ref": "Report.Тест", + "extension": "ТестРасширение", + "old": "СтарыйФрагмент", + "new": "НовыйФрагмент", + "execution_mode": "plan", + "repository_lock": {"lock_session_id": "lock-1"}, + } + ) + + # The resolver normally adds this guard; isolate the response contract in + # this test by setting it on the payload seen by code_write. + assert result["apply_hint"]["method"] == "code.write" + assert result["apply_hint"]["params"]["ref"] == "Report.Тест" + assert result["apply_hint"]["params"]["extension"] == "ТестРасширение" + assert "module_ref" not in result["apply_hint"]["params"] + + +def test_code_write_plan_returns_bounded_fragment_diff(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_write", + lambda _payload: { + "status": "planned", + "routed_method": "metadata.module.write_apply", + "result": { + "status": "planned", + "proposal": { + "counts": {"edits": 1}, + "edits": [{"old_text_sha1": "a" * 40, "new_text_sha1": "b" * 40}], + }, + }, + }, + ) + + result = adapter_server.code_write( + {"base_id": "upo", "ref": "Report.Тест", "old": "old", "new": "new", "execution_mode": "plan"} + ) + + assert result["fragment_replace"]["status"] == "planned_exact_replace" + assert result["fragment_replace"]["planned_edits"] == 1 + assert result["fragment_replace"]["diff"] == {"removed": "old", "added": "new"} + + +def test_code_write_plan_uses_contextual_anchors_without_replacing_them(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + def fake_metadata_write(payload: dict[str, Any]) -> dict[str, Any]: + captured.update(payload) + return { + "status": "planned", + "routed_method": "metadata.module.write_apply", + "result": {"status": "planned", "proposal": {"counts": {"edits": 1}}}, + } + + monkeypatch.setattr(adapter_server, "metadata_write", fake_metadata_write) + + result = adapter_server.code_write( + { + "base_id": "upo", + "ref": "Report.Тест", + "before": "Начало\n", + "old": "ПовторяемыйФрагмент", + "after": "\nКонец", + "new": "ИсправленныйФрагмент", + "execution_mode": "plan", + } + ) + + # Uniqueness is checked on the whole contextual fragment; the replacement + # itself preserves both anchors byte-for-byte. + assert captured["old"] == "Начало\nПовторяемыйФрагмент\nКонец" + assert captured["new"] == "Начало\nИсправленныйФрагмент\nКонец" + assert result["fragment_replace"]["diff"] == { + "removed": "ПовторяемыйФрагмент", + "added": "ИсправленныйФрагмент", + } + assert result["fragment_replace"]["context"] == {"before": "Начало\n", "after": "\nКонец"} + hint = result["apply_hint"]["params"] + assert {key: hint[key] for key in ("base_id", "ref", "old", "new", "execution_mode", "before", "after")} == { + "base_id": "upo", + "ref": "Report.Тест", + "old": "ПовторяемыйФрагмент", + "new": "ИсправленныйФрагмент", + "execution_mode": "apply_and_verify", + "before": "Начало\n", + "after": "\nКонец", + } + + +def test_code_replacement_candidates_returns_safe_context_not_ordinals() -> None: + source = "До\nПовтор\nПослеПервого\nДо\nПовтор\nПослеВторого" + + candidates = adapter_server.code_replacement_candidates(source, "Повтор", anchor_chars=20) + + assert len(candidates) == 2 + assert [candidate["line"] for candidate in candidates] == [2, 5] + assert all("ordinal" not in candidate for candidate in candidates) + assert all(candidate["status"] == "ready" for candidate in candidates) + assert candidates[0]["before"] != candidates[1]["before"] or candidates[0]["after"] != candidates[1]["after"] + + +def test_code_write_accepts_ready_replacement_candidate(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + monkeypatch.setattr( + adapter_server, + "metadata_write", + lambda payload: (captured.update(payload) or {"status": "planned", "result": {"status": "planned"}}), + ) + + result = adapter_server.code_write( + { + "replacement_candidate": { + "status": "ready", + "before": "До\n", + "old": "Старое", + "after": "\nПосле", + "write_selector": { + "base_id": "upo_test", + "ref": "Report.Тест", + "extension": "ТестРасширение", + "expected_text_sha1": "a" * 40, + }, + }, + "new": "Новое", + "execution_mode": "plan", + } + ) + + assert captured["old"] == "До\nСтарое\nПосле" + assert captured["new"] == "До\nНовое\nПосле" + assert captured["expected_text_sha1"] == "a" * 40 + assert result["apply_hint"]["params"]["ref"] == "Report.Тест" + + +def test_saved_state_ensure_is_exposed_through_public_dispatch(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_prepare", + lambda _payload: {"status": "already_prepared", "base_id": "upo_test", "applied": False}, + ) + + result = call_method("metadata.saved_state.ensure", {"base_id": "upo_test", "ref": "Catalog.Номенклатура"}) + + assert result["schema"] == "onec_saved_state_ensure.v1" + assert result["status"] == "ready" + + +def test_saved_state_ensure_can_be_enqueued_as_job(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_start_job(payload: dict[str, Any]) -> dict[str, Any]: + seen.update(payload) + return {"schema": "onec_adapter_job.v1", "status": "accepted", "job_id": "job-1"} + + monkeypatch.setattr(adapter_server, "adapter_start_job", fake_start_job) + + result = call_method( + "metadata.saved_state.ensure", + {"base_id": "upo_test", "ref": "Catalog.Номенклатура", "execution_mode": "job"}, + ) + + assert result["status"] == "accepted" + assert seen["method"] == "metadata.saved_state.ensure" + assert seen["payload"] == {"base_id": "upo_test", "ref": "Catalog.Номенклатура"} + + +def test_saved_state_ensure_rollback_requires_receipt_and_delegates(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_rollback(base_id: str, receipt_id: str, *, timeout_seconds: int) -> dict[str, Any]: + seen.update(base_id=base_id, receipt_id=receipt_id, timeout_seconds=timeout_seconds) + return {"status": "applied", "applied": True} + + monkeypatch.setattr(adapter_server, "rollback_saved_state_prepare_receipt", fake_rollback) + receipt_id = "a" * 32 + + result = call_method( + "metadata.saved_state.ensure.rollback", + {"base_id": "upo_test", "prepare_receipt_id": receipt_id, "allow_sql_saved_state_rollback": True}, + ) + + assert result["schema"] == "onec_saved_state_ensure_rollback.v1" + assert result["status"] == "applied" + assert seen == {"base_id": "upo_test", "receipt_id": receipt_id, "timeout_seconds": 60} + + def test_saved_state_prepare_rejects_conflicting_semantic_and_storage_layers() -> None: result = adapter_server.metadata_saved_state_prepare( { @@ -25692,6 +30264,61 @@ def test_saved_state_prepare_resolves_extension_route_cache(monkeypatch: pytest. assert result["counts"]["source_rows"] == 2 +def test_saved_state_prepare_resolves_extension_form_through_public_owner(monkeypatch: pytest.MonkeyPatch) -> None: + seen: dict[str, Any] = {} + + def fake_forms(payload: dict[str, Any]) -> dict[str, Any]: + seen.update(payload) + return { + "status": "ok", + "forms": [ + {"name": "ФормаОтчета", "source": {"table": "ConfigCAS", "file_name": "ext__form.0"}}, + ], + } + + monkeypatch.setattr(adapter_server, "metadata_object_forms", fake_forms) + file_names, object_card, error = adapter_server.saved_state_prepare_file_names( + { + "base_id": "upo_test", + "extension": "фс_Отчеты", + "ref": "Report.Продкорзина", + "form": "ФормаОтчета", + }, + "upo_test", + "ConfigCAS", + 30, + ) + + assert error is None + assert file_names == ["ext__form.0"] + assert object_card["mode"] == "extension_form_owner" + assert seen["ref"] == "Report.Продкорзина" + assert seen["form"] == "ФормаОтчета" + + +def test_saved_state_prepare_rejects_bare_extension_form_name_without_global_scan() -> None: + file_names, _object_card, error = adapter_server.saved_state_prepare_file_names( + {"base_id": "upo_test", "extension": "фс_Отчеты", "form": "ФормаОтчета"}, + "upo_test", + "ConfigCAS", + 30, + ) + + assert file_names == [] + assert error["error"] == "form_owner_required" + assert error["diagnostics"]["next_call"]["method"] == "metadata.object.forms" + + +def test_saved_state_prepare_public_form_owner_error_keeps_next_call() -> None: + result = adapter_server.metadata_saved_state_prepare( + {"base_id": "upo_test", "extension": "фс_Отчеты", "form": "ФормаОтчета", "mode": "plan"} + ) + + assert result["status"] == "invalid_argument" + assert result["error"] == "form_owner_required" + assert result["next_call"]["method"] == "metadata.object.forms" + + def test_saved_state_diff_compares_active_and_saved_payload(monkeypatch: pytest.MonkeyPatch) -> None: reads: list[tuple[str, str]] = [] @@ -25724,6 +30351,81 @@ def test_saved_state_diff_compares_active_and_saved_payload(monkeypatch: pytest. assert result["freshness"]["status"] == "live_sql_verified" +def test_saved_state_diff_uses_selected_bsl_stream_for_text_same(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_read_storage_file_bytes(base_id: str, table: str, file_name: str, *, timeout_seconds: int = 30): + value = b"active-container" if table == "ConfigCAS" else b"saved-container" + return value, {"database": "upo_test"}, None + + def fake_extract(data: bytes, *, module_ref: str, bsl_offset: int | None = None): + return "Процедура Одинаковая()\nКонецПроцедуры\n", { + "status": "ok", + "source": "stream", + "stream_index": 4, + } + + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", fake_read_storage_file_bytes) + monkeypatch.setattr(adapter_server, "extract_code_index_text_from_payload", fake_extract) + + result = adapter_server.metadata_saved_state_diff( + { + "base_id": "upo_test", + "module_ref": "ConfigCASSave:form-module.0#stream:4", + } + ) + + assert result["status"] == "changed" + assert result["comparison"]["differs"] is True + assert result["comparison"]["container_text_same"] is False + assert result["comparison"]["module_text_same"] is True + assert result["comparison"]["text_same"] is True + assert result["module_text"]["same"] is True + assert result["module_text"]["active_sha1"] == result["module_text"]["saved_sha1"] + + +def test_saved_state_diff_falls_back_to_logical_module_when_active_file_name_changed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_read_storage_file_bytes(base_id: str, table: str, file_name: str, *, timeout_seconds: int = 30): + if table == "ConfigCASSave": + return b"saved-container", {"database": "upo_test"}, None + return None, {"database": "upo_test"}, { + "status": "source_missing", + "error": "source_missing", + "diagnostics": {"message": "physical CAS key changed"}, + } + + monkeypatch.setattr(adapter_server, "read_storage_file_bytes", fake_read_storage_file_bytes) + monkeypatch.setattr( + adapter_server, + "saved_state_module_text_comparison_from_live_read", + lambda *args, **kwargs: { + "scope": "module", + "same": False, + "differs": True, + "active_sha1": "active-sha1", + "saved_sha1": "saved-sha1", + "active_chars": 20, + "saved_chars": 21, + "diff_lines": ["-active", "+saved"], + "diff_truncated": False, + "counterpart_resolution": {"strategy": "logical_owner_and_module_role"}, + }, + ) + + result = adapter_server.metadata_saved_state_diff( + { + "base_id": "upo_test", + "module_ref": "ConfigCASSave:extension__module.0#stream:4", + } + ) + + assert result["status"] == "changed" + assert result["comparison"]["module_text_same"] is False + assert result["comparison"]["container_text_same"] is None + assert result["module_text"]["counterpart_resolution"]["strategy"] == "logical_owner_and_module_role" + assert result["freshness"]["status"] == "live_sql_verified" + + def test_saved_state_diff_reports_prepare_needed_when_save_missing(monkeypatch: pytest.MonkeyPatch) -> None: def fake_read_storage_file_bytes(base_id: str, table: str, file_name: str, *, timeout_seconds: int = 30): if table == "ConfigSave": @@ -26010,6 +30712,1052 @@ def test_saved_state_status_rejects_conflicting_semantic_and_storage_layers() -> assert result["argument"] == "layer" +def test_configuration_activation_status_aggregates_live_layers_without_cache( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen: list[dict[str, Any]] = [] + + def fake_status(payload: dict[str, Any]) -> dict[str, Any]: + seen.append(payload) + changed = payload["layer"] == "extension_saved_state" + return { + "schema": "onec_saved_state_status.v1", + "method": "metadata.saved_state.status", + "status": "changed" if changed else "unchanged", + "counts": { + "saved_records": 3 if changed else 1, + "changed_objects": 1 if changed else 0, + "saved_only_objects": 1 if changed else 0, + "changed_parts": 4 if changed else 0, + }, + "freshness": {"status": "live_verified"}, + } + + monkeypatch.setattr(adapter_server, "metadata_saved_state_status", fake_status) + + result = adapter_server.call_method( + "configuration.activation.status", + {"base_id": "upo_test"}, + ) + + assert [item["layer"] for item in result["layers"]] == [ + "base_saved_state", + "extension_saved_state", + ] + assert result["status"] == "activation_required" + assert result["activation_required"] is True + assert result["counts"] == { + "changed_objects": 1, + "saved_only_objects": 1, + "pending_objects": 2, + "changed_parts": 4, + } + assert result["freshness"] == { + "source": "live_sql", + "status": "live_verified", + "uses_cache": False, + "uses_vector_index": False, + "scan_complete": True, + } + assert result["execution"]["performed"] is False + assert all(call["include_storage"] is False for call in seen) + assert all(call["include_unchanged"] is False for call in seen) + + +def test_configuration_activation_status_is_conservative_when_scan_hits_limit( + 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": "unchanged", + "counts": { + "saved_records": payload["limit"], + "changed_objects": 0, + "saved_only_objects": 0, + "changed_parts": 0, + }, + "freshness": {"status": "live_verified"}, + }, + ) + + result = adapter_server.call_method( + "configuration.activation.status", + { + "base_id": "upo_test", + "layer": "extension_saved_state", + "limit": 10, + }, + ) + + assert result["status"] == "inconclusive" + assert result["activation_required"] is None + assert result["freshness"]["scan_complete"] is False + assert result["layers"][0]["scan_complete"] is False + + +def test_configuration_activation_plan_returns_manual_handoff_and_verification( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + adapter_server, + "configuration_activation_status", + lambda payload: { + "schema": "onec_configuration_activation_status.v1", + "method": "configuration.activation.status", + "status": "activation_required", + "base_id": payload["base_id"], + "activation_required": True, + "scope": { + "layer": payload.get("layer") or "all", + "layers_checked": ["extension_saved_state"], + }, + }, + ) + monkeypatch.setattr(adapter_server.repository_control, "external_1c_enabled", lambda: True) + + result = adapter_server.call_method( + "configuration.activation.plan", + { + "base_id": "upo_test", + "layer": "extension_saved_state", + }, + ) + + assert result["status"] == "manual_activation_required" + assert result["next_step"]["kind"] == "manual_1c_designer_action" + assert result["next_step"]["confirmation_required"] is True + assert result["review_call"]["method"] == "metadata.saved_state.changes.list" + assert result["review_call"]["params"]["layer"] == "extension_saved_state" + assert result["verification_call"] == { + "method": "configuration.activation.status", + "params": { + "base_id": "upo_test", + "layer": "extension_saved_state", + "limit": 5000, + }, + "expected_status_after_activation": "up_to_date", + } + assert result["bridge"]["external_1c_enabled"] is True + assert result["bridge"]["activation_execution_available"] is False + assert result["execution"]["performed"] is False + assert result["execution"]["allowed"] is False + + +def test_configuration_activation_state_migrates_legacy_json_to_local_sqlite_once( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + legacy_path = tmp_path / "activation-requests.json" + state_db = tmp_path / "adapter-state.sqlite" + request_id = "actreq-" + "1" * 32 + legacy_path.write_text( + json.dumps( + { + "schema": "onec_configuration_activation_requests.v1", + "requests": { + request_id: { + "request_id": request_id, + "base_id": "upo_test", + "layer": "extension_saved_state", + "fingerprint": "a" * 64, + "status": "pending", + "created_at": 100.0, + "expires_at": 1000.0, + "events": [ + { + "event": "requested", + "at": 100.0, + } + ], + } + }, + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + monkeypatch.setenv("ONEC_CONFIGURATION_ACTIVATION_STATE_FILE", str(legacy_path)) + monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(state_db)) + + migrated = adapter_server.configuration_activation_read_state() + + assert migrated["requests"][request_id]["status"] == "pending" + assert migrated["requests"][request_id]["events"] == [ + {"event": "requested", "at": 100.0} + ] + with sqlite3.connect(state_db) as conn: + request_row = conn.execute( + "SELECT base_id, status FROM configuration_activation_requests " + "WHERE request_id=?", + (request_id,), + ).fetchone() + event_row = conn.execute( + "SELECT event FROM configuration_activation_events WHERE request_id=?", + (request_id,), + ).fetchone() + migration_row = conn.execute( + "SELECT value FROM adapter_state_meta " + "WHERE key='legacy_activation_requests_imported'" + ).fetchone() + assert request_row == ("upo_test", "pending") + assert event_row == ("requested",) + assert migration_row == ("yes",) + + legacy_path.write_text( + json.dumps({"requests": {request_id: {**migrated["requests"][request_id], "status": "cancelled"}}}), + encoding="utf-8", + ) + reloaded = adapter_server.configuration_activation_read_state() + assert reloaded["requests"][request_id]["status"] == "pending" + + +def test_configuration_activation_request_and_debug_execute_are_fingerprinted( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + state_path = tmp_path / "activation-requests.json" + monkeypatch.setenv("ONEC_CONFIGURATION_ACTIVATION_STATE_FILE", str(state_path)) + state_db = tmp_path / "adapter-state.sqlite" + monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(state_db)) + live_sha1 = {"value": "a" * 40} + + def fake_status(payload: dict[str, Any]) -> dict[str, Any]: + return { + "schema": "onec_configuration_activation_status.v1", + "method": "configuration.activation.status", + "status": "activation_required", + "base_id": payload["base_id"], + "activation_required": True, + "scope": { + "layer": payload.get("layer") or "all", + "layers_checked": ["extension_saved_state"], + }, + "counts": { + "changed_objects": 1, + "saved_only_objects": 0, + "pending_objects": 1, + "changed_parts": 2, + }, + "layers": [ + { + "layer": "extension_saved_state", + "status": "activation_required", + "counts": {"pending_objects": 1}, + "storage": { + "files": [ + { + "file_name": "module.0", + "status": "changed", + "changed_parts": 2, + "saved_sha1": [live_sha1["value"]], + "active_sha1": ["b" * 40], + } + ] + }, + } + ], + "freshness": { + "source": "live_sql", + "status": "live_verified", + "uses_cache": False, + "uses_vector_index": False, + "scan_complete": True, + }, + "execution": { + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + }, + } + + monkeypatch.setattr(adapter_server, "configuration_activation_status", fake_status) + monkeypatch.setattr( + adapter_server.repository_control, + "_run_designer", + lambda *args, **kwargs: pytest.fail("Designer must not run in debug mode"), + ) + + requested = adapter_server.call_method( + "configuration.activation.request", + { + "base_id": "upo_test", + "layer": "extension_saved_state", + "ttl_seconds": 600, + }, + ) + + assert requested["status"] == "pending" + assert requested["request_id"].startswith("actreq-") + assert len(requested["fingerprint"]) == 64 + assert requested["next_call"]["method"] == "configuration.activation.execute" + assert requested["next_call"]["params"]["mode"] == "debug" + assert "storage" not in json.dumps(requested, ensure_ascii=False) + assert state_db.exists() + assert not state_path.exists() + + confirmation_required = adapter_server.call_method( + "configuration.activation.execute", + { + "base_id": "upo_test", + "request_id": requested["request_id"], + "mode": "debug", + "confirm_activation": False, + }, + ) + assert confirmation_required["status"] == "confirmation_required" + assert confirmation_required["execution"]["performed"] is False + + accepted = adapter_server.call_method( + "configuration.activation.execute", + { + "base_id": "upo_test", + "request_id": requested["request_id"], + "mode": "debug", + "confirm_activation": True, + }, + ) + + assert accepted["status"] == "debug_accepted" + assert accepted["command_preview"] == { + "runner": "1c_designer", + "mode": "debug", + "operation": "manual_extension_database_configuration_update", + "layer": "extension_saved_state", + "executable": False, + "reason": "extension_activation_cli_not_implemented", + } + assert accepted["execution"] == { + "mode": "debug", + "performed": False, + "designer_started": False, + "active_configuration_changed": False, + } + request_status = adapter_server.call_method( + "configuration.activation.request.status", + { + "base_id": "upo_test", + "request_id": requested["request_id"], + }, + ) + assert request_status["status"] == "debug_accepted" + assert request_status["fingerprint"] == requested["fingerprint"] + confirmation_required_cancel = adapter_server.call_method( + "configuration.activation.request.cancel", + { + "base_id": "upo_test", + "request_id": requested["request_id"], + "confirm_cancel": False, + }, + ) + assert confirmation_required_cancel["status"] == "confirmation_required" + cancelled = adapter_server.call_method( + "configuration.activation.request.cancel", + { + "base_id": "upo_test", + "request_id": requested["request_id"], + "confirm_cancel": True, + }, + ) + assert cancelled["status"] == "cancelled" + assert cancelled["previous_status"] == "debug_accepted" + assert cancelled["execution"]["performed"] is False + audit = adapter_server.call_method( + "configuration.activation.audit", + {"base_id": "upo_test"}, + ) + assert audit["status"] == "ok" + assert audit["count"] == 1 + assert audit["status_counts"] == {"cancelled": 1} + assert [event["event"] for event in audit["requests"][0]["events"]] == [ + "requested", + "debug_accepted", + "cancelled", + ] + serialized_audit = json.dumps(audit, ensure_ascii=False) + assert requested["fingerprint"] not in serialized_audit + assert "module.0" not in serialized_audit + + +def test_configuration_activation_debug_execute_blocks_changed_fingerprint( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv( + "ONEC_CONFIGURATION_ACTIVATION_STATE_FILE", + str(tmp_path / "activation-requests.json"), + ) + monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(tmp_path / "adapter-state.sqlite")) + live_sha1 = {"value": "a" * 40} + + def fake_status(payload: dict[str, Any]) -> dict[str, Any]: + return { + "schema": "onec_configuration_activation_status.v1", + "method": "configuration.activation.status", + "status": "activation_required", + "base_id": payload["base_id"], + "activation_required": True, + "scope": {"layer": payload.get("layer") or "all"}, + "counts": {"pending_objects": 1}, + "layers": [ + { + "layer": "base_saved_state", + "status": "activation_required", + "counts": {"pending_objects": 1}, + "storage": { + "files": [ + { + "file_name": "root", + "status": "changed", + "saved_sha1": [live_sha1["value"]], + "active_sha1": ["b" * 40], + } + ] + }, + } + ], + } + + monkeypatch.setattr(adapter_server, "configuration_activation_status", fake_status) + requested = adapter_server.call_method( + "configuration.activation.request", + {"base_id": "upo_test", "layer": "base_saved_state"}, + ) + live_sha1["value"] = "c" * 40 + + result = adapter_server.call_method( + "configuration.activation.execute", + { + "base_id": "upo_test", + "request_id": requested["request_id"], + "mode": "debug", + "confirm_activation": True, + }, + ) + + assert result["status"] == "blocked" + assert result["error"] == "activation_fingerprint_changed" + assert result["expected_fingerprint"] != result["current_fingerprint"] + assert result["next_call"]["method"] == "configuration.activation.request" + assert result["execution"]["performed"] is False + + +def test_configuration_activation_execute_can_require_runner_debug_receipt( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv( + "ONEC_CONFIGURATION_ACTIVATION_STATE_FILE", + str(tmp_path / "activation-requests.json"), + ) + monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(tmp_path / "adapter-state.sqlite")) + + def fake_status(payload: dict[str, Any]) -> dict[str, Any]: + return { + "schema": "onec_configuration_activation_status.v1", + "method": "configuration.activation.status", + "status": "activation_required", + "base_id": payload["base_id"], + "activation_required": True, + "scope": {"layer": payload.get("layer") or "all"}, + "counts": {"pending_objects": 1}, + "layers": [ + { + "layer": "base_saved_state", + "status": "activation_required", + "counts": {"pending_objects": 1}, + "storage": { + "files": [ + { + "file_name": "root", + "status": "changed", + "saved_sha1": ["a" * 40], + "active_sha1": ["b" * 40], + } + ] + }, + } + ], + } + + monkeypatch.setattr(adapter_server, "configuration_activation_status", fake_status) + monkeypatch.setattr( + adapter_server.repository_control, + "repository_config", + lambda base_id, layer_id="base": ( + {"runner": {"kind": "http", "url": "http://runner:8121"}}, + None, + ), + ) + bridge_calls = [] + + def fake_bridge(base_id, config, *, layer, timeout_seconds, request_id="", fingerprint=""): + bridge_calls.append( + { + "base_id": base_id, + "layer": layer, + "timeout_seconds": timeout_seconds, + "request_id": request_id, + "fingerprint": fingerprint, + } + ) + return { + "status": "ready", + "runner": {"kind": "local"}, + "debug_acceptance": { + "accepted": True, + "request_id": request_id, + "fingerprint": fingerprint, + "receipt": "c" * 64, + }, + "execution": { + "performed": False, + "designer_started": False, + }, + } + + monkeypatch.setattr( + adapter_server.repository_control, + "activation_debug_probe", + fake_bridge, + ) + monkeypatch.setattr( + adapter_server.repository_control, + "_run_designer", + lambda *args, **kwargs: pytest.fail("Designer must not run during bridge debug acceptance"), + ) + requested = adapter_server.call_method( + "configuration.activation.request", + {"base_id": "upo_test", "layer": "base_saved_state"}, + ) + + result = adapter_server.call_method( + "configuration.activation.execute", + { + "base_id": "upo_test", + "request_id": requested["request_id"], + "mode": "debug", + "confirm_activation": True, + "bridge_debug": True, + "timeout_seconds": 12, + }, + ) + + assert result["status"] == "debug_accepted" + assert result["bridge_debug"] == { + "status": "accepted", + "runner_kind": "local", + "request_id": requested["request_id"], + "fingerprint": requested["fingerprint"], + "receipt": "c" * 64, + "execution_performed": False, + "designer_started": False, + } + assert result["verification_call"] == { + "method": "configuration.activation.verify", + "params": { + "base_id": "upo_test", + "request_id": requested["request_id"], + }, + "expected_status_after_real_activation": "verified_up_to_date", + } + assert bridge_calls == [ + { + "base_id": "upo_test", + "layer": "base_saved_state", + "timeout_seconds": 12, + "request_id": requested["request_id"], + "fingerprint": requested["fingerprint"], + } + ] + audit = adapter_server.call_method( + "configuration.activation.audit", + {"base_id": "upo_test"}, + ) + assert [event["event"] for event in audit["requests"][0]["events"]] == [ + "requested", + "debug_accepted", + "bridge_debug_accepted", + ] + assert "c" * 64 not in json.dumps(audit, ensure_ascii=False) + + +def test_configuration_activation_execute_blocks_mismatched_bridge_receipt( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv( + "ONEC_CONFIGURATION_ACTIVATION_STATE_FILE", + str(tmp_path / "activation-requests.json"), + ) + monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(tmp_path / "adapter-state.sqlite")) + monkeypatch.setattr( + adapter_server, + "configuration_activation_status", + lambda payload: { + "schema": "onec_configuration_activation_status.v1", + "method": "configuration.activation.status", + "status": "activation_required", + "base_id": payload["base_id"], + "activation_required": True, + "scope": {"layer": payload.get("layer") or "all"}, + "counts": {"pending_objects": 1}, + "layers": [ + { + "layer": "base_saved_state", + "status": "activation_required", + "counts": {"pending_objects": 1}, + "storage": { + "files": [ + { + "file_name": "root", + "status": "changed", + "saved_sha1": ["a" * 40], + "active_sha1": ["b" * 40], + } + ] + }, + } + ], + }, + ) + monkeypatch.setattr( + adapter_server.repository_control, + "repository_config", + lambda *args, **kwargs: ({"runner": {"kind": "http"}}, None), + ) + monkeypatch.setattr( + adapter_server.repository_control, + "activation_debug_probe", + lambda base_id, config, **kwargs: { + "status": "ready", + "runner": {"kind": "local"}, + "debug_acceptance": { + "accepted": True, + "request_id": kwargs["request_id"], + "fingerprint": "f" * 64, + "receipt": "c" * 64, + }, + }, + ) + requested = adapter_server.call_method( + "configuration.activation.request", + {"base_id": "upo_test", "layer": "base_saved_state"}, + ) + + result = adapter_server.call_method( + "configuration.activation.execute", + { + "base_id": "upo_test", + "request_id": requested["request_id"], + "confirm_activation": True, + "bridge_debug": True, + }, + ) + + assert result["status"] == "blocked" + assert result["error"] == "activation_bridge_debug_not_accepted" + assert result["bridge"]["accepted"] is True + assert result["bridge"]["receipt_valid"] is False + request_status = adapter_server.call_method( + "configuration.activation.request.status", + {"base_id": "upo_test", "request_id": requested["request_id"]}, + ) + assert request_status["status"] == "pending" + + +def test_configuration_activation_verify_distinguishes_pending_and_aligned_state( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv( + "ONEC_CONFIGURATION_ACTIVATION_STATE_FILE", + str(tmp_path / "activation-requests.json"), + ) + monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(tmp_path / "adapter-state.sqlite")) + phase = {"value": "pending"} + + def fake_status(payload: dict[str, Any]) -> dict[str, Any]: + if phase["value"] == "aligned": + return { + "schema": "onec_configuration_activation_status.v1", + "method": "configuration.activation.status", + "status": "up_to_date", + "base_id": payload["base_id"], + "activation_required": False, + "scope": {"layer": payload.get("layer") or "all"}, + "counts": {"pending_objects": 0}, + "layers": [], + "freshness": { + "source": "live_sql", + "status": "live_verified", + "uses_cache": False, + }, + } + return { + "schema": "onec_configuration_activation_status.v1", + "method": "configuration.activation.status", + "status": "activation_required", + "base_id": payload["base_id"], + "activation_required": True, + "scope": {"layer": payload.get("layer") or "all"}, + "counts": {"pending_objects": 1}, + "layers": [ + { + "layer": "extension_saved_state", + "status": "activation_required", + "counts": {"pending_objects": 1}, + "storage": { + "files": [ + { + "file_name": "module.0", + "status": "changed", + "saved_sha1": ["a" * 40], + "active_sha1": ["b" * 40], + } + ] + }, + } + ], + "freshness": { + "source": "live_sql", + "status": "live_verified", + "uses_cache": False, + }, + } + + monkeypatch.setattr(adapter_server, "configuration_activation_status", fake_status) + requested = adapter_server.call_method( + "configuration.activation.request", + {"base_id": "upo_test", "layer": "extension_saved_state"}, + ) + + pending = adapter_server.call_method( + "configuration.activation.verify", + { + "base_id": "upo_test", + "request_id": requested["request_id"], + }, + ) + + assert pending["status"] == "not_activated" + assert pending["next_step"]["action"] == "update_database_configuration" + assert pending["retry_call"]["method"] == "configuration.activation.verify" + phase["value"] = "aligned" + + verified = adapter_server.call_method( + "configuration.activation.verify", + { + "base_id": "upo_test", + "request_id": requested["request_id"], + }, + ) + + assert verified["status"] == "verified_up_to_date" + assert verified["outcome"] == { + "saved_and_active_aligned": True, + "activation_proven": False, + "reason": ( + "Live SQL proves that saved and active layers align. It cannot distinguish " + "Designer activation from another operation that removed or replaced saved changes." + ), + } + assert verified["execution"]["performed"] is False + request_status = adapter_server.call_method( + "configuration.activation.request.status", + {"base_id": "upo_test", "request_id": requested["request_id"]}, + ) + assert request_status["status"] == "verified_up_to_date" + audit = adapter_server.call_method( + "configuration.activation.audit", + {"base_id": "upo_test"}, + ) + assert [event["event"] for event in audit["requests"][0]["events"]] == [ + "requested", + "verification_not_activated", + "verified_up_to_date", + ] + + +def test_configuration_activation_verify_marks_changed_fingerprint( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv( + "ONEC_CONFIGURATION_ACTIVATION_STATE_FILE", + str(tmp_path / "activation-requests.json"), + ) + monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(tmp_path / "adapter-state.sqlite")) + saved_sha1 = {"value": "a" * 40} + + def fake_status(payload: dict[str, Any]) -> dict[str, Any]: + return { + "schema": "onec_configuration_activation_status.v1", + "method": "configuration.activation.status", + "status": "activation_required", + "base_id": payload["base_id"], + "activation_required": True, + "scope": {"layer": payload.get("layer") or "all"}, + "counts": {"pending_objects": 1}, + "layers": [ + { + "layer": "base_saved_state", + "status": "activation_required", + "counts": {"pending_objects": 1}, + "storage": { + "files": [ + { + "file_name": "root", + "status": "changed", + "saved_sha1": [saved_sha1["value"]], + "active_sha1": ["b" * 40], + } + ] + }, + } + ], + } + + monkeypatch.setattr(adapter_server, "configuration_activation_status", fake_status) + requested = adapter_server.call_method( + "configuration.activation.request", + {"base_id": "upo_test", "layer": "base_saved_state"}, + ) + saved_sha1["value"] = "c" * 40 + + result = adapter_server.call_method( + "configuration.activation.verify", + { + "base_id": "upo_test", + "request_id": requested["request_id"], + }, + ) + + assert result["status"] == "changed_since_request" + assert result["next_call"] == { + "method": "configuration.activation.request", + "params": {"base_id": "upo_test", "layer": "base_saved_state"}, + } + request_status = adapter_server.call_method( + "configuration.activation.request.status", + {"base_id": "upo_test", "request_id": requested["request_id"]}, + ) + assert request_status["status"] == "changed_since_request" + + +def test_configuration_activation_execute_rejects_real_mode() -> None: + result = adapter_server.call_method( + "configuration.activation.execute", + { + "base_id": "upo_test", + "request_id": "actreq-test", + "mode": "apply", + "confirm_activation": True, + }, + ) + + assert result["status"] == "invalid_argument" + assert result["argument"] == "mode" + assert result["allowed_values"] == ["debug"] + + +def test_configuration_activation_capabilities_hide_runner_details( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("ONEC_ADAPTER_ENABLE_EXTERNAL_1C", "true") + monkeypatch.setattr( + adapter_server.repository_control, + "repository_config", + lambda base_id, layer_id="base": ( + { + "designer_path": r"C:\Secret\1cv8.exe", + "infobase": {"server": "secret-server/secret-base"}, + "infobase_user": "secret-user", + "infobase_password_env": "SECRET_PASSWORD_ENV", + "runner": { + "kind": "http", + "url": "http://secret-runner:8121", + "token_env": "SECRET_TOKEN_ENV", + }, + }, + None, + ), + ) + + result = adapter_server.call_method( + "configuration.activation.capabilities", + {"base_id": "upo_test"}, + ) + + assert result["status"] == "ok" + assert result["workflow"]["debug_acceptance"] is True + assert result["workflow"]["real_execution"] is False + assert result["layers"]["base_saved_state"]["designer_operation"] == "/UpdateDBCfg" + assert result["layers"]["extension_saved_state"]["status"] == "manual_only" + assert result["runner"] == { + "configured": True, + "kind": "http", + "ready_for_future_base_activation": True, + "external_1c_enabled": True, + "designer_path_configured": True, + "infobase_selector_configured": True, + "problem": None, + } + serialized = json.dumps(result, ensure_ascii=False) + for secret_fragment in ( + "Secret", + "secret-server", + "secret-base", + "secret-user", + "SECRET_PASSWORD_ENV", + "secret-runner", + "SECRET_TOKEN_ENV", + ): + assert secret_fragment not in serialized + + +def test_configuration_activation_bridge_probe_checks_local_runner_without_execution( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + designer = tmp_path / "1cv8.exe" + designer.write_bytes(b"test") + monkeypatch.setattr( + adapter_server.repository_control, + "repository_config", + lambda base_id, layer_id="base": ( + { + "designer_path": str(designer), + "infobase": {"server": "secret-server/secret-base"}, + "runner": {"kind": "local"}, + }, + None, + ), + ) + monkeypatch.setattr( + adapter_server.repository_control, + "_run_designer", + lambda *args, **kwargs: pytest.fail("Designer must not run during bridge probe"), + ) + + result = adapter_server.call_method( + "configuration.activation.bridge.probe", + { + "base_id": "upo_test", + "layer": "base_saved_state", + }, + ) + + assert result["status"] == "ready" + assert result["runner"] == { + "kind": "local", + "reachable": True, + "designer_path_configured": True, + "designer_available": True, + "infobase_selector_configured": True, + } + assert result["operation"] == { + "kind": "/UpdateDBCfg", + "execution_supported": False, + "extension_manual_only": False, + } + assert result["execution"]["performed"] is False + serialized = json.dumps(result, ensure_ascii=False) + assert str(designer) not in serialized + assert "secret-server" not in serialized + assert "secret-base" not in serialized + + +def test_configuration_activation_bridge_probe_sanitizes_http_runner_response( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + adapter_server.repository_control, + "repository_config", + lambda base_id, layer_id="base": ( + { + "runner": { + "kind": "http", + "url": "http://secret-runner:8121", + "token_env": "SECRET_TOKEN", + } + }, + None, + ), + ) + monkeypatch.setattr( + adapter_server.repository_control, + "activation_debug_probe", + lambda *args, **kwargs: { + "status": "ready", + "runner": { + "kind": "http", + "reachable": True, + "designer_available": True, + "secret_url": "http://must-not-leak", + }, + "operation": { + "kind": None, + "execution_supported": False, + "extension_manual_only": True, + "raw_command": "must-not-leak", + }, + "path": r"C:\must-not-leak\1cv8.exe", + }, + ) + + result = adapter_server.call_method( + "configuration.activation.bridge.probe", + { + "base_id": "upo_test", + "layer": "extension_saved_state", + }, + ) + + assert result["status"] == "ready" + assert result["runner"] == { + "kind": "http", + "reachable": True, + "designer_available": True, + } + assert result["operation"] == { + "kind": None, + "execution_supported": False, + "extension_manual_only": True, + } + serialized = json.dumps(result, ensure_ascii=False) + assert "must-not-leak" not in serialized + assert "secret-runner" not in serialized + assert "SECRET_TOKEN" not in serialized + + +def test_configuration_activation_methods_are_registered_and_validate_layer() -> None: + methods = { + row["name"]: row + for row in adapter_server.call_method("help.methods", {})["methods"] + } + + assert methods["configuration.activation.status"]["input_schema"]["required"] == ["base_id"] + assert methods["configuration.activation.plan"]["http_routes"] == [{"verb": "POST"}] + assert methods["configuration.activation.request"]["input_schema"]["properties"]["ttl_seconds"]["minimum"] == 60 + assert methods["configuration.activation.request.cancel"]["input_schema"]["properties"]["confirm_cancel"]["const"] is True + assert methods["configuration.activation.audit"]["input_schema"]["required"] == ["base_id"] + assert methods["configuration.activation.capabilities"]["input_schema"]["required"] == ["base_id"] + assert methods["configuration.activation.bridge.probe"]["input_schema"]["properties"]["timeout_seconds"]["maximum"] == 60 + assert methods["configuration.activation.execute"]["input_schema"]["properties"]["mode"]["enum"] == ["debug"] + assert methods["configuration.activation.execute"]["input_schema"]["properties"]["bridge_debug"]["type"] == "boolean" + assert methods["configuration.activation.verify"]["input_schema"]["required"] == ["base_id", "request_id"] + result = adapter_server.call_method( + "configuration.activation.status", + {"base_id": "upo_test", "layer": "extension"}, + ) + 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"] @@ -26661,6 +32409,7 @@ def test_metadata_write_active_module_ref_auto_prepares_when_apply_allowed(monke def test_metadata_write_missing_module_target_exposes_prepare_plan(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(adapter_server, "resolve_write_gate_context", lambda payload: payload) monkeypatch.setattr( adapter_server, "metadata_saved_state_modules_search", @@ -26760,6 +32509,52 @@ def test_metadata_write_missing_module_target_auto_prepares_and_retries(monkeypa assert result["status"] == "verified" +def test_metadata_write_retry_resolves_prepared_extension_object_module_by_canonical_name(monkeypatch: pytest.MonkeyPatch) -> None: + """Prepare/retry remains public even when the generic stream search stays empty.""" + active_hash = "a" * 40 + canonical_name = "8e02accd-8a34-11f1-8294-005056b0d483__e064659b-6bf8-4162-8c3d-ae186c151ff1.0" + state = {"prepared": False, "searches": 0} + + def fake_prepare(payload: dict[str, Any]) -> dict[str, Any]: + if payload.get("mode") == "apply_and_verify": + state["prepared"] = True + return {"status": "verified", "apply_result": {"prepare_receipt": {"receipt_id": "receipt-1"}}} + return {"status": "plan_ready", "source_rows": [{"source_file_name": active_hash, "file_name": canonical_name}]} + + def fake_read_module(payload: dict[str, Any]) -> dict[str, Any]: + if payload["module_ref"] != f"ConfigCASSave:{canonical_name}#stream:4" or not state["prepared"]: + return {"status": "not_found"} + return {"status": "ok", "text": "Процедура Проверка()\nКонецПроцедуры", "content_sha1": "b" * 40} + + monkeypatch.setattr(adapter_server, "metadata_saved_state_prepare", fake_prepare) + monkeypatch.setattr(adapter_server, "metadata_object_modules", lambda _payload: {"status": "ok", "modules": [{"kind": "object_module", "module_id": f"ConfigCAS:{active_hash}#stream:4"}]}) + monkeypatch.setattr(adapter_server, "read_module", fake_read_module) + monkeypatch.setattr( + adapter_server, + "metadata_saved_state_modules_search", + lambda _payload: {"status": "ok", "modules": []}, + ) + + resolved = adapter_server.metadata_write_resolve_module_target( + { + "base_id": "upo_test", + "extension": "фс_Отчеты1", + "ref": "Report.фс_ПродовольственнаяКорзина", + "old": "Проверка", + "new": "Проверка2", + "allow_sql_saved_state_apply": True, + }, + {"kind": "module"}, + "apply_and_verify", + ) + + assert isinstance(resolved, tuple) + write_payload, resolution = resolved + assert write_payload["module_ref"] == f"ConfigCASSave:{canonical_name}#stream:4" + assert write_payload["_auto_prepare_receipt_id"] == "receipt-1" + assert resolution["public_module_resolution"]["status"] == "resolved_public_object_module" + + def test_saved_state_apply_requires_explicit_allow_flag() -> None: result = adapter_server.storage_saved_state_apply_proposal( { @@ -26814,6 +32609,8 @@ def test_saved_state_apply_updates_single_part_with_backup(monkeypatch: pytest.M def execute(self, sql: str, params: tuple[Any, ...]) -> None: self.store["sql"].append(sql) + if sql.strip().upper().startswith("SET LOCK_TIMEOUT"): + return if sql.strip().upper().startswith("SELECT"): self.rowcount = 1 return @@ -26853,7 +32650,7 @@ def test_saved_state_apply_updates_single_part_with_backup(monkeypatch: pytest.M monkeypatch.setattr( adapter_server, "invalidate_adapter_caches_after_saved_state_change", - lambda base_id, *, reason: {"status": "ok", "base_id": base_id, "reason": reason}, + lambda base_id, *, reason, **kwargs: {"status": "ok", "base_id": base_id, "reason": reason}, ) proposal = { @@ -26878,7 +32675,7 @@ def test_saved_state_apply_updates_single_part_with_backup(monkeypatch: pytest.M assert state["data"] == replacement assert state["committed"] is True assert state["rolled_back"] is False - assert "WITH (UPDLOCK, HOLDLOCK)" in state["sql"][0] + assert any("WITH (UPDLOCK, HOLDLOCK)" in sql for sql in state["sql"]) assert result["cache_invalidation"]["reason"] == "saved_state_payload_apply" assert Path(result["backup"]["path"]).exists() backup = json.loads(Path(result["backup"]["path"]).read_text(encoding="utf-8")) @@ -27154,7 +32951,7 @@ def test_saved_state_rollback_loads_backup_by_id(monkeypatch: pytest.MonkeyPatch "original": {"sha1": "old", "payload_hex": "00"}, "replacement": {"sha1": "new"}, "rollback": { - "payload": { + "request": { "base_id": "upo_test", "allow_sql_saved_state_apply": True, "proposal": { @@ -27189,6 +32986,18 @@ def test_saved_state_rollback_loads_backup_by_id(monkeypatch: pytest.MonkeyPatch assert seen["payload"]["base_id"] == "upo_test" +def test_saved_state_rollback_preserves_public_support_selector(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + backup_id = "a" * 32 + backup_path = tmp_path / f"backup-{backup_id}.json" + backup_path.write_text(json.dumps({"backup_id": backup_id, "rollback": {"payload": {"proposal": {}}}}), encoding="utf-8") + monkeypatch.setenv("ONEC_ADAPTER_BACKUP_DIR", str(tmp_path)) + seen: dict[str, Any] = {} + monkeypatch.setattr(adapter_server, "storage_saved_state_apply_proposal", lambda payload: seen.setdefault("payload", payload) or {"status": "applied", "applied": True}) + adapter_server.storage_saved_state_rollback({"base_id": "upo_test", "backup_id": backup_id, "allow_sql_saved_state_rollback": True, "ref": "Report.Тест", "kind": "Report", "name": "Тест"}) + assert seen["payload"]["ref"] == "Report.Тест" + assert seen["payload"]["kind"] == "Report" + + def test_swap_brace_text_paths_preserves_surrounding_format() -> None: from parser.payload import parse_brace_text, scalar, swap_brace_text_paths, get_tree_path @@ -27304,6 +33113,38 @@ def test_changes_propose_repairs_mojibake_stream_before_replace(monkeypatch: pyt assert not result["edits"][0]["new_text_preview"].startswith("\ufeff") +def test_changes_propose_resizes_complete_declared_bsl_stream(monkeypatch: pytest.MonkeyPatch) -> None: + """A declared BSL stream without an opaque tail is safely resizable.""" + from parser.cas_payload import stream_header, structural_stream_blocks_with_data + + old = "// Старый\r\n" + new = "// Новый комментарий переменной длины\r\n" + source = ("\ufeff" + old).encode("utf-8") + stored = compress_payload(stream_header(len(source)) + source, "raw_deflate") + monkeypatch.setattr( + adapter_server, + "read_storage_file_bytes", + lambda *_args, **_kwargs: (stored, {"database": "upo_test"}, None), + ) + + result = adapter_server.changes_propose( + { + "base_id": "upo_test", + "source": {"table": "ConfigCASSave", "file_name": "object__module.2", "module_id": "ConfigCASSave:object__module.2#stream:0"}, + "edits": [{"stream_index": 0, "replace": {"old": old, "new": new, "count": 1}}], + "include_payload": True, + } + ) + + assert result["status"] == "accepted_for_review" + assert result["edits"][0]["mode"] == "stream" + assert result["edits"][0]["old_bytes"] != result["edits"][0]["new_bytes"] + decoded = decode_payload_lossless(bytes.fromhex(result["encoded"]["payload_hex"])) + block = structural_stream_blocks_with_data(bytes(decoded["payload"]))[0] + assert block["declared_1"] == block["declared_2"] == result["edits"][0]["new_bytes"] + assert new in str(block["text"]) + + @pytest.mark.parametrize( ("annotation", "expected"), [ @@ -27410,6 +33251,117 @@ def test_metadata_resolve_overrides_marks_unknown_extension_action(monkeypatch: assert "intent" not in evidence +def test_metadata_resolve_overrides_uses_saved_extension_form_by_public_object(monkeypatch: pytest.MonkeyPatch) -> None: + active_ref = "ConfigCAS:" + ("a" * 40) + "#form_module" + saved_ref = "ConfigCASSave:fb26cf42-7609-11f1-828f-005056b0d483__77494708-43ea-4956-ac3c-199cfb035ad2.0#form_module" + read_refs: list[str] = [] + monkeypatch.setattr( + adapter_server, + "metadata_object_modules", + lambda payload: { + "status": "ok", + "object": { + "kind": "Catalog", + "name": "test2", + "guid": "e7e322f5-a7a6-4ddc-bad0-344e75ad00d0", + "storage": {"table": "ConfigCAS", "file_name": "hidden"}, + "origin": {"extension": {"name": "test2", "guid": "fb26cf42-7609-11f1-828f-005056b0d483"}}, + }, + "modules": [ + { + "module_id": active_ref, + "kind": "form_module", + "form": {"name": "t_Форма", "guid": "77494708-43ea-4956-ac3c-199cfb035ad2"}, + } + ], + }, + ) + monkeypatch.setattr( + adapter_server, + "code_read_saved_counterpart_module_ref", + lambda base_id, module_ref, payload: saved_ref, + ) + + def fake_read(payload: dict[str, Any]) -> dict[str, Any]: + read_refs.append(str(payload.get("module_ref") or "")) + return { + "status": "ok", + "text": '&После("ЗаменаДомена")\nПроцедура РасширениеЗаменаДомена()\nКонецПроцедуры', + "selection": { + "routine_name": "ЗаменаДомена", + "line_start": 1, + "line_end": 3, + "match_by": "routine_exact", + "operation_class": "insert_after", + }, + } + + monkeypatch.setattr(adapter_server, "read_module", fake_read) + + result = adapter_server.metadata_resolve_overrides( + { + "base_id": "upo_test", + "ref": "Catalog.test2", + "method_name": "ЗаменаДомена", + "state": "working", + } + ) + + assert result["status"] == "ok" + assert read_refs == [saved_ref] + assert result["chain"][0]["source"] == "extension" + assert result["chain"][0]["activation_state"] == "saved_state" + assert result["chain"][0]["module"]["form"] == "t_Форма" + assert result["chain"][0]["read_selector"]["module_ref"] == saved_ref + assert result["chain"][0]["extension_action"]["operation_class"] == "insert_after" + assert "storage" not in result["object"] + + +def test_metadata_resolve_overrides_classifies_plain_extension_owned_routine_as_definition( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + adapter_server, + "metadata_object_modules", + lambda payload: { + "status": "ok", + "object": { + "kind": "Catalog", + "name": "test2", + "guid": "e7e322f5-a7a6-4ddc-bad0-344e75ad00d0", + "origin": {"source": "extension", "extension": {"name": "test2"}}, + }, + "modules": [{"module_id": "ConfigCAS:" + ("a" * 40) + "#form_module", "kind": "form_module"}], + }, + ) + monkeypatch.setattr(adapter_server, "code_read_saved_counterpart_module_ref", lambda *args, **kwargs: None) + monkeypatch.setattr( + adapter_server, + "read_module", + lambda payload: { + "status": "ok", + "text": "Процедура ЗаменаДомена()\nКонецПроцедуры", + "selection": {"line_start": 1, "line_end": 2, "match_by": "routine_exact"}, + }, + ) + + result = adapter_server.metadata_resolve_overrides( + { + "base_id": "upo_test", + "ref": "Catalog.test2", + "method_name": "ЗаменаДомена", + "state": "working", + } + ) + + action = result["chain"][0]["extension_action"] + assert action["status"] == "ok" + assert action["operation_class"] == "extension_definition" + assert result["write_plan_evidence"]["target"]["extension_action"] == action + assert "intent" not in result["write_plan_evidence"] + assert adapter_server.metadata_write_plan_extension_action_problem(action, "replace_with_control", False) is None + + def test_metadata_resolve_overrides_preserves_replace_with_control_action(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( adapter_server, @@ -27725,6 +33677,50 @@ def test_form_radio_button_auto_type_and_columns_count() -> None: assert props["ColumnsCount"] == "1" +def test_extension_filter_rejects_registered_inactive_extension(monkeypatch: pytest.MonkeyPatch) -> None: + guid = "11111111-1111-1111-1111-111111111111" + inactive = {guid: {"guid": guid, "name": "ВыключенноеРасширение", "active": False}} + monkeypatch.setattr(adapter_server, "extension_map_by_guid", lambda _base_id: {}) + monkeypatch.setattr(adapter_server, "registered_extension_map_by_guid", lambda _base_id: inactive) + + resolved, error = adapter_server.extension_filter_to_guid("upo_test", guid, method="modules.search") + + assert resolved is None + assert error is not None + assert error["status"] == "unavailable" + assert error["error"] == "extension_inactive" + + +def test_call_dispatch_rejects_inactive_extension_guid_before_route(monkeypatch: pytest.MonkeyPatch) -> None: + guid = "11111111-1111-1111-1111-111111111111" + monkeypatch.setattr( + adapter_server, + "extension_filter_to_guid", + lambda _base_id, _selector, method: (None, { + "schema": f"onec_{method.replace('.', '_')}.v1", + "status": "unavailable", + "error": "extension_inactive", + }), + ) + + result = adapter_server.call_method_impl("modules.search", {"base_id": "upo_test", "extension_guid": guid, "query": "Probe"}) + + assert result["status"] == "unavailable" + assert result["error"] == "extension_inactive" + + +def test_inactive_extension_cassave_file_route_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + guid = "11111111-1111-1111-1111-111111111111" + expected = {"status": "unavailable", "error": "extension_inactive"} + monkeypatch.setattr(adapter_server, "reject_inactive_extension_scope", lambda *_args, **_kwargs: expected) + + result = adapter_server.reject_inactive_storage_file( + "upo_test", "ConfigCASSave", f"{guid}__configinfo", method="storage.file.get" + ) + + assert result == expected + + diff --git a/tests/1c/test_repository_control.py b/tests/1c/test_repository_control.py index d8f949c..23e176a 100644 --- a/tests/1c/test_repository_control.py +++ b/tests/1c/test_repository_control.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import sqlite3 import sys from pathlib import Path @@ -50,6 +51,80 @@ def configured_layers(monkeypatch, tmp_path: Path) -> None: monkeypatch.setenv("ONEC_REPOSITORY_STATE_FILE", str(tmp_path / "locks.json")) +def test_repositoryless_base_inherits_test_write_profile_to_discovered_extension(monkeypatch, tmp_path: Path) -> None: + config = { + "upo_test": { + "server": "sql", "database": "upo_test", "user": "reader", + "development_layers": { + "base": { + "repository": {"mode": "none", "connection_state": "not_configured"}, + "support": {"mode": "unknown"}, + }, + }, + }, + } + extension_guid = "11111111-2222-3333-4444-555555555555" + monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(config)) + monkeypatch.setenv("ONEC_REPOSITORY_STATE_FILE", str(tmp_path / "locks.json")) + + payload = {"base_id": "upo_test", "extension_guid": extension_guid} + repository, repository_error = repository_control.repository_config("upo_test", f"extension:{extension_guid}") + + assert repository_error is None + assert repository["mode"] == "none" + assert repository["inherited_from_layer"] == "base" + assert repository_control.write_gate(payload)["allowed"] is True + support = repository_control.support_gate(payload) + assert support["allowed"] is True + assert support["status"] == "not_on_support_inherited_no_repository" + + +def test_repository_policy_is_not_inherited_from_real_base_to_extension(monkeypatch, tmp_path: Path) -> None: + configured_layers(monkeypatch, tmp_path) + + repository, error = repository_control.repository_config( + "base", "extension:99999999-2222-3333-4444-555555555555" + ) + + assert repository is None + assert error["status"] == "layer_not_configured" + + +def test_repository_state_migrates_once_to_local_sqlite(monkeypatch, tmp_path: Path) -> None: + legacy_path = tmp_path / "locks.json" + state_path = tmp_path / "adapter-state.sqlite" + legacy = { + "requests": { + "rreq-legacy": { + "base_id": "base", + "layer_id": "base", + "status": "cancelled", + "created_at": 1.0, + } + }, + "sessions": {}, + "audit": [{"event": "legacy_event", "time": 1.0, "base_id": "base"}], + } + legacy_path.write_text(json.dumps(legacy), encoding="utf-8") + original = legacy_path.read_bytes() + monkeypatch.setenv("ONEC_REPOSITORY_STATE_FILE", str(legacy_path)) + monkeypatch.setenv("ONEC_ADAPTER_STATE_DB", str(state_path)) + + state = repository_control._read_state() + + assert state["requests"]["rreq-legacy"]["status"] == "cancelled" + assert legacy_path.read_bytes() == original + with sqlite3.connect(state_path) as conn: + assert conn.execute( + "SELECT value FROM adapter_state_meta WHERE key='legacy_repository_state_imported'" + ).fetchone()[0] == "1" + assert int(conn.execute( + "SELECT value FROM adapter_state_meta WHERE key='adapter_state_schema_version'" + ).fetchone()[0]) >= 2 + assert conn.execute("SELECT COUNT(*) FROM repository_lock_requests").fetchone()[0] == 1 + assert conn.execute("SELECT COUNT(*) FROM repository_lock_events").fetchone()[0] == 1 + + 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") @@ -70,6 +145,89 @@ def test_http_runner_profile_does_not_require_local_designer_or_credentials(monk assert "designer_path" not in configured +def test_activation_debug_probe_calls_dedicated_http_endpoint_without_execution( + monkeypatch, +) -> None: + captured = {} + + class Response: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps( + { + "status": "ready", + "runner": { + "kind": "local", + "reachable": True, + "designer_available": True, + "infobase_selector_configured": True, + }, + "operation": { + "kind": "/UpdateDBCfg", + "execution_supported": False, + }, + "execution": { + "mode": "debug", + "performed": False, + "designer_started": False, + }, + "debug_acceptance": { + "accepted": True, + "request_id": "actreq-" + "1" * 32, + "fingerprint": "a" * 64, + "receipt": "b" * 64, + }, + } + ).encode("utf-8") + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["body"] = json.loads(request.data.decode("utf-8")) + captured["authorization"] = request.headers.get("Authorization") + captured["timeout"] = timeout + return Response() + + monkeypatch.setenv("ACTIVATION_RUNNER_TOKEN", "runner-secret") + monkeypatch.setattr(repository_control.urllib.request, "urlopen", fake_urlopen) + + result = repository_control.activation_debug_probe( + "upo_test", + { + "runner": { + "kind": "http", + "url": "http://runner:8121", + "token_env": "ACTIVATION_RUNNER_TOKEN", + } + }, + layer="base_saved_state", + timeout_seconds=7, + request_id="actreq-" + "1" * 32, + fingerprint="a" * 64, + ) + + assert result["status"] == "ready" + assert captured == { + "url": "http://runner:8121/configuration/activation/debug", + "body": { + "base_id": "upo_test", + "layer": "base_saved_state", + "mode": "debug", + "request_id": "actreq-" + "1" * 32, + "fingerprint": "a" * 64, + }, + "authorization": "Bearer runner-secret", + "timeout": 7, + } + assert result["execution"]["performed"] is False + assert result["execution"]["designer_started"] is False + assert result["debug_acceptance"]["receipt"] == "b" * 64 + + def test_lock_plan_maps_child_metadata_to_development_owner() -> None: result = repository_control.lock_plan({"object": "РегистрСведений.Настройки.Реквизит.Код"}) assert result["status"] == "ready" @@ -121,6 +279,9 @@ def test_manual_lock_request_stays_pending_until_user_confirms_exact_saved_scope } pending = repository_control.lock_request_status({"request_id": requested["request_id"]}) assert pending["status"] == "pending_user_lock" + assert pending["base_id"] == "base" + assert pending["objects"] == ["РегистрСведений.Настройки"] + assert pending["native_lock_state"] == "unknown" assert pending["next_call"] == requested["next_call"] incomplete = repository_control.confirm_manual_lock({"base_id": "base", "request_id": requested["request_id"]}) @@ -494,6 +655,9 @@ def test_confirm_write_context_can_be_forwarded_under_repository_lock() -> None: assert context["lock_session_id"] == "rlock-test" assert context["repository_object"] == "Document.тл_Планировщик.Form.ФормаДокументаНовая" assert context["layer_id"] == "base" + forwarded = adapter_1c_server.repository_write_context(context) + assert forwarded["layer_id"] == "base" + assert forwarded["lock_session_id"] == "rlock-test" _context, conflict = adapter_1c_server.normalize_repository_write_context({ "lock_session_id": "rlock-one", diff --git a/tests/1c/test_sql_base_access_policy.py b/tests/1c/test_sql_base_access_policy.py index ea73b0e..5860ce3 100644 --- a/tests/1c/test_sql_base_access_policy.py +++ b/tests/1c/test_sql_base_access_policy.py @@ -46,9 +46,9 @@ def test_designer_sql_decoding_policy_keeps_adapter_read_only() -> None: service = yaml.safe_load((CONNECTOR / "service.yaml").read_text(encoding="utf-8")) assert policy["scope"]["default_base_id"] == "upo_test" - assert policy["scope"]["adapter_role"] == "sql_observer_and_decoder" + assert policy["scope"]["adapter_role"] == "specification_bound_sql_decoder_and_controlled_saved_state_writer" assert policy["credentials"]["persistence"] == "forbidden_in_repository" - assert policy["sql_observation"]["adapter_access"] == "read_only" + assert policy["sql_observation"]["adapter_access"] == "sql_only" assert "direct_application_data_write" in policy["sql_observation"]["forbidden"] assert policy["xml"]["role"] == "offline_schema_reference_only" assert "policies/designer-sql-decoding-policy.yaml" in service["contracts"]["policies"] diff --git a/tests/1c/test_structural_stream_parser.py b/tests/1c/test_structural_stream_parser.py new file mode 100644 index 0000000..bb634df --- /dev/null +++ b/tests/1c/test_structural_stream_parser.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "plugins" / "1c")) + +from parser.cas_payload import ( # noqa: E402 + classify_payload, + decode_declared_utf8_bsl_prefix, + extract_stream_blocks, + replace_declared_utf8_bsl_prefix_same_width, + stream_header, + structural_stream_blocks_with_data, +) + + +def test_structural_stream_parser_ignores_header_signature_inside_member() -> None: + fake_header = stream_header(1) + first = b"prefix" + fake_header + b"tail" + payload = b"lead" + stream_header(len(first)) + first + stream_header(3) + b"end" + + # The old discovery scanner sees the signature embedded in `first`. + assert len(extract_stream_blocks(payload, include_text=True)) == 3 + + blocks = structural_stream_blocks_with_data(payload) + assert len(blocks) == 2 + assert blocks[0]["data"] == first + assert blocks[1]["data"] == b"end" + + +def test_declared_utf8_bsl_prefix_excludes_opaque_member_tail() -> None: + bsl = b"\xef\xbb\xbf// marker\r\n" + opaque = b"\x00\x04\x10metadata" + member = bsl + opaque + header = f"\r\n{len(bsl):08x} {len(member):08x} 7fffffff \r\n".encode("ascii") + payload = b"lead" + header + member + + decoded = decode_declared_utf8_bsl_prefix(payload, 0) + assert decoded["status"] == "ok" + assert decoded["text"] == "// marker\r\n" + assert decoded["bsl_prefix_bytes"] == len(bsl) + assert decoded["opaque_tail_bytes"] == len(opaque) + + +def test_classification_uses_declared_utf8_prefix_not_opaque_member_tail() -> None: + bsl = b"\xef\xbb\xbf// source-only\r\n" + opaque = b"\x00metadata-tail-that-is-not-bsl" + member = bsl + opaque + header = f"\r\n{len(bsl):08x} {len(member):08x} 7fffffff \r\n".encode("ascii") + + classified = classify_payload(b"lead" + header + member, include_text=True) + + stream = classified["stream_blocks"][0] + assert stream["text"] == "// source-only\r\n" + assert stream["declared_utf8_bsl_prefix"]["opaque_tail_bytes"] == len(opaque) + + +def test_classification_does_not_treat_declared_brace_prefix_as_bsl() -> None: + descriptor = b"\xef\xbb\xbf{3,1,0,\"\",0}" + member = descriptor + b"\x00opaque" + header = f"\r\n{len(descriptor):08x} {len(member):08x} 7fffffff \r\n".encode("ascii") + + classified = classify_payload(b"lead" + header + member, include_text=True) + + assert "declared_utf8_bsl_prefix" not in classified["stream_blocks"][0] + + +def test_fixed_width_prefix_replacement_preserves_tail_and_all_other_members() -> None: + first = b"first-member" + old_text = "//Пример - 3\r\n//Пример - 4 " + old_prefix = b"\xef\xbb\xbf" + old_text.encode("utf-8") + opaque = b"\x00opaque-platform-tail\x10\x20" + member = old_prefix + opaque + payload = ( + b"lead" + + stream_header(len(first)) + + first + + f"\r\n{len(old_prefix):08x} {len(member):08x} 7fffffff \r\n".encode("ascii") + + member + + stream_header(3) + + b"end" + ) + + replacement, evidence = replace_declared_utf8_bsl_prefix_same_width( + payload, + 1, + text="//Тест - 3\r\n//Пример - 4 ", + ) + + before = structural_stream_blocks_with_data(payload) + after = structural_stream_blocks_with_data(replacement) + assert evidence["padding_bytes"] == len("Пример".encode("utf-8")) - len("Тест".encode("utf-8")) + assert before[0]["data"] == after[0]["data"] + assert before[1]["data"][len(old_prefix) :] == after[1]["data"][len(old_prefix) :] + assert before[2]["data"] == after[2]["data"] + assert decode_declared_utf8_bsl_prefix(replacement, 1)["text"].startswith("//Тест - 3") + + +def test_fixed_width_prefix_replacement_rejects_growth() -> None: + old_prefix = b"\xef\xbb\xbf// one" + member = old_prefix + b"opaque" + payload = b"lead" + f"\r\n{len(old_prefix):08x} {len(member):08x} 7fffffff \r\n".encode("ascii") + member + + try: + replace_declared_utf8_bsl_prefix_same_width(payload, 0, text="// replacement that is longer") + except ValueError as exc: + assert str(exc) == "replacement_declared_bsl_prefix_exceeds_fixed_width" + else: + raise AssertionError("fixed-width codec accepted growth") diff --git a/tests/1c/test_write_handler_registry.py b/tests/1c/test_write_handler_registry.py new file mode 100644 index 0000000..cf6e5ff --- /dev/null +++ b/tests/1c/test_write_handler_registry.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +CONNECTOR = Path(__file__).resolve().parents[2] / "plugins" / "1c" / "connector" +if str(CONNECTOR) not in sys.path: + sys.path.insert(0, str(CONNECTOR)) + +from write.registry import registered_handlers, select_write_handler # noqa: E402 +from write.context import AdapterWriteContext # noqa: E402 +from write.handlers.scheduled_job import execute as execute_scheduled_job # noqa: E402 + + +def test_registry_selects_one_supported_handler() -> None: + assert select_write_handler(target_kind="module").key == "module" + assert select_write_handler(target_kind="форма").key == "form" + assert select_write_handler(target_kind="object", operation="add_attribute").key == "object_member" + assert select_write_handler(target_kind="object", operation="synonym").key == "object_property" + assert select_write_handler(target_kind="anything", is_schedule=True).key == "scheduled_job_schedule" + + +def test_registry_denies_unknown_target_by_default() -> None: + assert select_write_handler(target_kind="template") is None + + +def test_registry_exposes_each_typed_handler_once() -> None: + handlers = registered_handlers() + assert [item["key"] for item in handlers] == [ + "module", "form", "object_property", "object_member", "scheduled_job_schedule" + ] + + +def test_scheduled_job_handler_uses_explicit_context() -> None: + seen: list[dict] = [] + result = execute_scheduled_job( + {"base_id": "upo_test"}, + AdapterWriteContext(legacy_scheduled_job_writer=lambda payload: seen.append(payload) or {"status": "planned"}), + ) + assert result == {"status": "planned"} + assert seen == [{"base_id": "upo_test"}]