Complete name-first 1C adapter saved-state support

This commit is contained in:
2026-07-26 16:39:53 +03:00
parent b8c62fa8fa
commit aed134d817
44 changed files with 15436 additions and 697 deletions
+143
View File
@@ -0,0 +1,143 @@
{
"schema": "onec_metadata_kind_fixture_manifest.v1",
"default_base_id": "upo_test",
"required_kinds": [
"CalculationRegister",
"Sequence",
"Interface"
],
"policy": {
"provisioning_tool": "1c_designer",
"direct_sql_write_allowed": false,
"apply_to_active_configuration_by_default": false,
"credentials_in_manifest_allowed": false
},
"designer": {
"required_version": "8.5.1.1236",
"access_mode": "operating_system_integrated_only",
"credential_arguments_allowed": false,
"export_script": "scripts/export_1c_extension_sources.ps1"
},
"external_reference": {
"repository": "https://github.com/asosnoviy/go1cover.git",
"commit": "c7e3ea3d90396d341cb98285c29973bcc0a82eca",
"source_path": "testresourse/cf/designer/src",
"license": "GPL-3.0",
"usage": "external_reference_only_not_vendored",
"kind_paths": {
"CalculationRegister": "CalculationRegisters/РегистрРасчета1.xml",
"Sequence": "Sequences/Последовательность1.xml",
"Interface": "Interfaces/Интерфейс1.xml"
}
},
"fixtures": [
{
"id": "calculation_register_saved_extension",
"kind": "CalculationRegister",
"kind_ru": "РегистрРасчета",
"coverage_before_fixture": "decoder_and_unit_tests",
"target": {
"mode": "extension_saved_state",
"base_id": "upo_test",
"platform_version": "8.5.1.1236",
"extension": "test2",
"state": "working"
},
"selector": {
"names": [
"tt_РегистрРасчета1",
"tt_CalcRegister1"
],
"guid": "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5"
},
"dependencies": [
{
"kind": "ChartOfCalculationTypes",
"name": "tt_ВидыРасчета1"
}
],
"expected_activation_states": [
"saved_only",
"saved_override",
"active"
],
"smoke_methods": [
"metadata.object.get",
"metadata.object.properties",
"metadata.object.attributes",
"metadata.object.related"
],
"provisioning": {
"operation": "create_or_load_with_designer",
"save_extension": true,
"apply_extension": false,
"rollback": "remove_fixture_objects_with_designer"
}
},
{
"id": "sequence_saved_extension",
"kind": "Sequence",
"kind_ru": "Последовательность",
"coverage_before_fixture": "generic_decoder_and_unit_discovery",
"target": {
"mode": "extension_saved_state",
"base_id": "upo_test",
"platform_version": "8.5.1.1236",
"extension": "test2",
"state": "working"
},
"selector": {
"names": [
"tt_Последовательность1",
"tt_Sequence1"
]
},
"dependencies": [],
"expected_activation_states": [
"saved_only",
"saved_override",
"active"
],
"smoke_methods": [
"metadata.object.get",
"metadata.object.properties",
"metadata.object.attributes"
],
"provisioning": {
"operation": "create_or_load_with_designer",
"save_extension": true,
"apply_extension": false,
"rollback": "remove_fixture_objects_with_designer"
}
},
{
"id": "interface_legacy_base",
"kind": "Interface",
"kind_ru": "Интерфейс",
"coverage_before_fixture": "root_collection_discovery_only",
"target": {
"mode": "dedicated_base_active",
"base_override_required": true
},
"selector": {
"names": [
"tt_Интерфейс1",
"tt_Interface1"
]
},
"dependencies": [],
"expected_activation_states": [
"active"
],
"smoke_methods": [
"metadata.object.get"
],
"provisioning": {
"operation": "load_legacy_configuration_with_designer",
"save_extension": false,
"apply_extension": false,
"rollback": "discard_dedicated_test_base"
}
}
]
}
@@ -9,3 +9,9 @@ ADAPTER_1C_HOST_PORT=8011
ONEC_SQL_BASES_JSON=
# Optional path inside the container to a JSON file with the same shape as ONEC_SQL_BASES_JSON.
ONEC_SQL_BASES_JSON_FILE=/data/onec-sql-bases.json
# Adapter-owned local SQLite state. Never point this at the 1C database.
ONEC_ADAPTER_STATE_DB=/data/adapter-cache.sqlite
ONEC_ADAPTER_JOB_PROCESS_ISOLATION=true
ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB=0
ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS=0
+301 -23
View File
@@ -603,6 +603,22 @@ Important methods:
resolved through the platform `_BPr<N>` route internally.
- `data.present` returns a compact presentation for one `record_ref`, and
`data.movements` reads register rows for a `recorder_ref`.
- BSP access-key methods use the same object/record separation without
overloading their query-area selector. For `access.keys.query`,
`kind=object` selects the access-key area; it is not a metadata kind. Pass
`object_ref=Справочники.Номенклатура` to select the metadata object and
`record_ref=<32-hex-ref>` to select one application-data record.
`access.object_keys.resolve` and `access.object.explain` accept the same
pair. The adapter resolves `object_sql_number` and `object_id` internally.
Raw `object`, `object_id`, and `object_sql_number` remain compatibility
filters only and should not be generated by new MCP clients.
- `metadata.adapter.audit include_unmapped=true` distinguishes configuration
metadata from platform storage. Live `upo_test` DBNames verification showed
the former candidate roles `Acoustic`, `Bots`, `Ecs`, `LangModel`,
`MobileClientDataExchange`, `STT*`, `URLExternalData`, and
`WebSocketClients` as singleton platform-system tables, normally carrying
the zero GUID. `ExtDataSrcPrms` is auxiliary external-data-source storage.
None of these roles is exposed as an invented public metadata kind.
- `metadata.object.form.details` decodes form commands with their explicit
`Action` value from the SQL form payload. `command_links` resolve that action
against the complete form-module routine index; `routines_sample` is only a
@@ -722,8 +738,12 @@ Important methods:
- `CommonModule.<Name>` is a complete public SQL route: root discovery resolves
the object by its 1C name, and module APIs read its canonical `.0` Config
stream. Live `upo_test` discovery contains 3,400 common modules; sampled
modules decoded as BSL with public routine indexes. Common-module writes, when
explicitly enabled, remain limited to the saved-state layer.
modules decoded as BSL with public routine indexes. In public
`metadata.object.modules`, `modules.read`, and code-search responses, the
module `name`, `qualified_name`, and `display_name` are the 1C common-module
name; Config GUID/stream coordinates are exposed only with
`include_storage=true`. Common-module writes, when explicitly enabled, remain
limited to the saved-state layer.
- For `Role`, `metadata.object.properties` reads the separate SQL part
`<role-guid>.0` and returns `set_for_new_objects`,
`set_for_attributes_by_default`, `independent_rights_of_child_objects`,
@@ -752,6 +772,95 @@ Important methods:
`allow_full_scan=true`. Accounting-register totals are reported as
`unsupported_register` until account and subconto semantics are resolved for
the selected register; raw movements remain readable.
- All `data.*` methods publish the same name-first object selector contract in
`help.methods`: callers may pass `object_ref`, `ref`, `kind/name/guid`, or
MCP aliases `object_type/object_name/object_guid`. Prefer `object_ref` with a
1C code-style name such as `Справочники.Номенклатура` when a method also
needs `record_ref` or `recorder_ref`; a record reference is never treated as
the metadata object name.
- `metadata.support.decode` accepts the same optional public object selectors.
When `ref` or `kind/name` is supplied, the adapter resolves the metadata GUID
internally and returns the effective supplier-support rule for that named
object; callers do not need to discover or pass storage identifiers.
- `metadata.code_index.build` accepts an optional public object selector for a
targeted local-cache refresh. A named base object is resolved through
`Config`, while an extension object with `extension_guid` uses
`ConfigCASSave`; omitting the selector keeps the bounded global `ConfigCAS`
scan. The index remains a local derivative cache and never writes to the 1C
database.
- `metadata.form.owner_index.build` keeps the owner selector and form selector
separate. Use `ref`/`kind/name` for the owning 1C object and `form` or
`form_name` for the nested form. For example,
`ref=Обработки.ОбменДанными, form_name=ФормаОбмена` searches the owner by
`ОбменДанными` and indexes only the named form; the two names are not
substituted for one another.
- `metadata.form.command_button.verify` uses the same owner/form separation and
publishes `command_name`, `button_name`, and `handler_name` as distinct child
selectors. Saved forms with the same name are matched against owner evidence
before verification, so a form belonging to another object is not selected
merely because its form name matches.
- `metadata.form.command_button.write` uses the same disambiguation before it
creates any proposal. `object_guid` identifies the owner and `form_guid`
identifies the nested form; they are never substituted for one another.
Name-first selection does not weaken the write workflow:
`allow_saved_state_write`, repository apply checks,
`allow_sql_saved_state_apply`, verification, and rollback guards remain
mandatory in their existing execution modes.
- `metadata.form.write_target.resolve` and
`metadata.form.write_target.verify` use the same public hierarchy:
`ref`/`kind`/`name` select the form owner, `form_name`/`form_guid` select the
nested form, and `element`/`command`/`attribute` select the child target.
`object_guid` is never treated as a nested `form_guid`.
- `metadata.form.element.write` and `metadata.form.element.write_apply` pass
that hierarchy through unchanged when resolving the concrete saved-state
file. An owner `object_guid` therefore cannot bypass name-first form
resolution. Planning remains opt-in and SQL apply still requires the
existing explicit apply and repository gates.
- `metadata.form.target.move` resolves the owner and nested form before it
resolves `from_element` and `to_element`; structural moves cannot select a
same-named form owned by another object. Its write/apply/rollback gates are
unchanged.
- `metadata.form.write_matrix.build` and `.smoke` use the same owner/form
hierarchy while resolving the saved-state file. The build operation is
read-only; smoke remains restricted to explicit apply-and-rollback.
- `metadata.write_learning.capture_before` and `.capture_after` also accept
the public owner `ref` plus a separate form selector. They write only local
learning artifacts; the selected 1C saved-state file is read, not modified.
- `metadata.saved_state.forms.search` accepts the same owner scope and filters
decoded saved forms by owner evidence before returning them. Its default
response contains the public owner ref, form name, qualified name, and
name-based selectors for matched elements, commands, and attributes.
SQL files, GUID identities, internal brace paths/markers, and low-level
writable-property coordinates are returned only with
`include_storage=true`. `form_guid` remains a separate exact diagnostic
selector. The method is read-only.
- `metadata.saved_state.modules.search` accepts a public module-owner `ref` or
`kind/name/guid`, resolves the internal saved-state prefix itself, and keeps
`file_name`/`stream_index` as opt-in diagnostic selectors. By default its
rows contain only the 1C owner, nested form, module role, qualified name,
source preview, and a semantic selector. SQL tables/files, GUID identities,
hashes, `module_ref`, and low-level write targets are returned only with
`include_storage=true`. Callers do not need to know an owner GUID for
name-first saved-code search.
- `code.write` is the public name-first write facade. It accepts the module
owner through `ref` or `kind/name/guid`, with `routine_name` as a separate
child selector, then resolves the concrete saved-state module internally.
`metadata.module.write_apply` remains the guarded low-level apply operation
for an already resolved `module_ref`.
- `templates.areas.find` accepts a public template ref such as
`Макеты.ПечатнаяФорма`. The template selector, named-area selector
(`area_name`/`area_query`), and low-level direct route are separate MCP
fields; normal callers do not need a storage route.
- `metadata.cache.lookup` accepts a public object `ref` in addition to
`kind/name/guid`. Its result is only a local identity-cache hint; operations
that require current evidence still verify against live SQL.
- `metadata.module_owner_cache.prune` accepts the cached module owner through
a public `ref` or `kind/name` and resolves `owner_guid` internally. Generated
`module_ref` values remain valid for narrow follow-up cleanup. The method
changes only adapter-local SQLite state, never the 1C SQL database; use
`dry_run=true` to inspect the matching count without deleting cache rows.
- For backward compatibility, a legacy bare `guid` without an owner selector
may still identify a form. `object_guid` never gets that legacy treatment.
- Logical schema results are cached briefly. `refresh_cache=true` forces a live
metadata decode after a configuration change.
- `metadata.objects.list`: lists base/effective metadata objects only. It must
@@ -800,7 +909,10 @@ Important methods:
evidence from `modules.read` so write planning can still distinguish base,
saved state, extension, or unresolved CAS references.
- `metadata.adapter.audit`: reports recognized metadata kinds, public kind
counts, missing supported kinds, and unmapped DBNames roles.
counts, missing supported kinds, and unmapped DBNames roles. Every
`missing_supported_kinds` item has
`presence_status=supported_absent_in_selected_base`: absence in one
infobase is not reported as absence of adapter implementation.
- Base root discovery includes the configuration object itself, command groups,
document numerators, external data sources, and integration services. The
root collection UUID map is verified against object UUIDs from the XML export
@@ -816,6 +928,22 @@ python scripts/audit_1c_adapter_coverage.py --base-id upo_test --output reports/
The script reads the adapter bearer token from `ONEC_ADAPTER_TOKEN` and never
reads or prints the SQL password.
Rare-kind regression fixtures for `CalculationRegister`, `Sequence`, and the
legacy `Interface` are checked separately:
```text
python scripts/check_1c_metadata_kind_fixtures.py --live
```
The fixture checker is read-only. It reuses one extension saved-state query for
all fixtures on the same layer and does not force the slower active
`ConfigCAS` full scan. It requires an explicit dedicated-base mapping for
`Interface`.
The manifest pins the required Designer version and an external structural
reference. Use `scripts/export_1c_extension_sources.ps1` to export `test2`
through operating-system integrated authentication; the helper accepts no
infobase user or credential parameters and never writes platform SQL.
For a resumable read-only application-data audit, run the full public chain for
one object of every data-bearing metadata kind:
@@ -1967,28 +2095,63 @@ Selector rules:
`write-rollback-safety-mcp-smoke.json` for MCP. These reports must show the
method in `help.methods`, readable write history, and blocked rollback when
`allow_sql_saved_state_rollback` is absent.
- `metadata.saved_state.prepare` prepares an object's saved-state working copy.
Prefer a public selector (`ref` or `kind/name`) together with
`layer=base_saved_state|extension_saved_state`. Its default response reports
the selected 1C object, active/saved semantic layers, record counts, and
readiness without exposing database/table names, storage files, hashes, or
row details. `include_storage=true` retains the low-level diagnostic response.
`plan` remains the default and performs no write; `apply` and
`apply_and_verify` still require `allow_sql_saved_state_prepare=true`.
- Every public RPC `next_resolution`/`next_call` entry uses `{method, params}`.
`payload` is reserved for the outer RPC request envelope and internal
apply-hint bodies; it must not be used as the arguments field of a public
follow-up. Saved-state follow-ups use the semantic contract: `layer` plus a
public object `ref` or `kind/name`. Internal `target_table`, `source_table`,
`file_name`, `file_names`, `module_ref`, object GUIDs, and
`include_storage` are not copied into public follow-up payloads. If only an
opaque storage target is known, the follow-up reports `selector_required`
instead of exposing that target.
- `metadata.saved_state.diff` is the agent-facing read-only comparison between
a saved-state payload and its active SQL source. It accepts `table` +
`file_name` or `module_ref`, maps `ConfigSave -> Config` and
`ConfigCASSave -> ConfigCAS`, reads both payloads from live SQL, and returns
`changed`/`unchanged`, `needs_prepare`, current hashes, compact text/tree
diff, and `freshness.status=live_sql_verified`.
a saved-state module and its active source. Prefer a public 1C selector such
as `ref=Справочник.Номенклатура` or `kind/name` plus `module_ordinal`;
the adapter resolves the saved-state table and physical file internally.
Generated `module_ref` or `table` + `file_name` remain accepted for chained
tooling. Name-first responses hide storage coordinates unless
`include_storage=true`, and a missing save layer returns a name-first
`metadata.saved_state.prepare` payload. The method maps
`ConfigSave -> Config` and `ConfigCASSave -> ConfigCAS`, reads both payloads
from live SQL, and returns `changed`/`unchanged`, `needs_prepare`, current
hashes, compact text/tree diff, and
`freshness.status=live_sql_verified`.
Deployment verification persists REST/MCP smoke reports as
`saved-state-diff-smoke.json` and `saved-state-diff-mcp-smoke.json`.
- `metadata.saved_state.status` is the read-only overview for a whole save
layer. It accepts `table=ConfigSave|ConfigCASSave`, compares saved rows with
`Config`/`ConfigCAS` by `FileName`, `PartNo`, size, and SHA1, and reports
counts plus per-file statuses: `changed`, `unchanged`, or `saved_only`.
- `metadata.saved_state.status` is the read-only name-first overview for a whole
save layer. Prefer `layer=base_saved_state|extension_saved_state`; the default
response reports semantic aggregate state and object/record counts without
exposing SQL tables, database names, files, hashes, or internal diff
selectors. `include_storage=true` enables the diagnostic storage view, which
accepts the backward-compatible `table=ConfigSave|ConfigCASSave`, compares
saved rows with their active source by file part, size, and SHA1, and returns
per-file `changed`, `unchanged`, or `saved_only` statuses.
- `metadata.saved_state.changes.list` is the read-only pending-change overview
across `ConfigSave` and `ConfigCASSave` by default. It returns changed and
saved-only files with `diff_selector` links, or includes unchanged files when
`include_unchanged=true`. Pass `include_context=true` for best-effort
form/module context by file; the freshness of the pending-change list remains
live SQL verified, while context is explanatory metadata. Pass
across the base and extension saved-state layers. Use the semantic
`layer=base_saved_state|extension_saved_state` filter when only one layer is
needed; the low-level `table` filter is retained for compatibility. Its
default response is name-first: object/form/module context is resolved
automatically, layer names are semantic, and SQL tables/files, GUIDs, hashes, module refs, concrete diff
selectors, low-level actions, and SQL-specific freshness vocabulary are
hidden. Unresolved changes remain in the
counts and are marked `context_unresolved`. Use `include_storage=true` for
the previous per-file diagnostic view with concrete `diff_selector` links.
`include_unchanged=true` also includes unchanged entries. The freshness of
the pending-change list remains live SQL verified, while context is
explanatory metadata. Pass
`group_by_context=true` to also return compact `groups` keyed by resolved
form/module/object context; grouping uses the same best-effort context
enrichment and falls back to per-file groups when context cannot be resolved.
Each group includes compact `selectors`: per-file `diff` selectors, plus
In diagnostic storage mode each group includes compact `selectors`: per-file
`diff` selectors, plus
`module_refs` and `write_plan_targets` when the resolved module context
exposes them. Groups also include `next_actions`, derived from those
selectors, for safe follow-up calls such as inspecting a diff, reading a
@@ -2209,9 +2372,12 @@ Purpose:
- include `write_plan_evidence.next_resolution.method` and
`write_plan_evidence.next_resolution.params` for
`metadata.saved_state.modules.search`, so the agent has the next safe lookup
for `module_ref`. The saved-state module search accepts
`object_type`/`object_name`/`object_guid` as public selectors and resolves
names to `owner_guid` when possible. Its matching streams return
for the module. The follow-up uses `ref` or `kind/name` and the semantic
`layer=base_saved_state|extension_saved_state`; it does not expose
`owner_guid` or SQL table names. The saved-state module search also accepts
the object selector aliases and resolves names to storage identities
internally. With the explicit diagnostic
`include_storage=true` option, its matching streams return
`write_plan_target` with `module_ref`, `file_name`, `stream_index`, and
`expected_sha1` for the concrete `metadata.write.plan` target. This override
evidence itself is not a concrete saved-state write route.
@@ -2253,6 +2419,111 @@ Output schema:
onec_metadata_write.v1
```
Standard object identity properties use the same high-level method and public
1C selector:
```json
{
"method": "metadata.write",
"payload": {
"base_id": "<base-id>",
"target": {
"area": "object",
"kind": "Catalog",
"name": "Номенклатура",
"property": "synonym"
},
"value": "Номенклатура товаров",
"expected_old": "Товары",
"mode": "plan"
}
}
```
This route delegates to `metadata.object.property.write`. It resolves the
object GUID and exact saved-state tree path internally, supports only existing
`synonym` locales and scalar `comment`, and preserves the original payload
format. Apply modes retain SHA1/semantic preconditions, backup, readback
verification, and rollback. Object rename and structural collection edits are
not part of this route.
An existing child metadata identity uses the same method:
```json
{
"method": "metadata.object.property.write",
"payload": {
"base_id": "<base-id>",
"member_ref": "Catalog.Номенклатура.Attribute.Артикул",
"property": "comment",
"value": "Код товара поставщика",
"allow_saved_state_write": true,
"execution_mode": "plan"
}
}
```
`member_kind` + `member_name` is also accepted. If the same child name occurs
in several collections, callers must provide the full `member_ref`; the
adapter returns `ambiguous` instead of choosing a GUID. Supported existing
member categories are `Attribute`, `TabularSection`, `Dimension`, and
`Resource`, including Russian aliases.
Adding one requisite uses a separate clone-based method:
```json
{
"method": "metadata.object.member.add",
"payload": {
"base_id": "<base-id>",
"template_member_ref": "Catalog.Номенклатура.Attribute.Артикул",
"new_member_name": "КодПоставщика",
"new_member_synonym": "Код поставщика",
"allow_saved_state_write": true,
"execution_mode": "plan"
}
}
```
The adapter requires an existing `Attribute` template from the same collection,
generates a deterministic GUID internally, rejects duplicate names, clones the
template type/settings, and appends the cloned record to the same declared
collection. The proposal verifies both the appended node and updated declared
count. Apply/verify/rollback use the normal saved-state gates and backup.
For a tabular-section column, use the complete nested template path:
```text
Document.АвансовыйОтчет.TabularSection.Запасы.Attribute.Номенклатура
```
Name uniqueness and the deterministic GUID seed are scoped to that tabular
section, so identical column names in other table parts do not conflict.
Scheduled-job schedule planning uses the same method and a public 1C reference:
```json
{
"method": "metadata.write",
"payload": {
"base_id": "<base-id>",
"target": {
"kind": "schedule",
"ref": "РегламентныеЗадания.ОбменДанными"
},
"schedule": {
"begin_time": "09:00:00",
"week_days": [1, 2, 3, 4, 5]
},
"allow_saved_state_write": true,
"mode": "plan"
}
}
```
This route returns `onec_scheduled_job_schedule_write.v1`, resolves the object
name to its GUID internally, and targets only `<guid>.0` in `ConfigSave`.
Purpose:
- provide one agent-facing write entry point;
@@ -2285,6 +2556,13 @@ Purpose:
`metadata.write`;
- v1 routes `target.kind=form` to
`metadata.form.element.write_apply`;
- v1 routes `target.area=object` to
`metadata.object.property.write` for `synonym` and `comment`;
- v1 routes `target.area=object` with `operation=add_attribute` to
`metadata.object.member.add`;
- v1 routes `target.kind=schedule` to the named scheduled-job schedule writer;
scalar schedule fields and structural `week_days`/`months` resize are
supported through a verified full schedule-tree replacement;
- v1 routes saved-state form payload container module refs without `#stream`
to the embedded form payload writer when `routine_name` and `routine_text`
identify a single routine edit; this path preserves existing leading BSL
@@ -2297,8 +2575,8 @@ Purpose:
- preserve the same gates as the lower-level form runner:
`plan`, `apply`, `apply_and_rollback`, explicit SQL apply/rollback flags,
sha1 preconditions, backup, and semantic verification;
- keep future non-form writers behind explicit target routing instead of
ad-hoc direct SQL methods.
- keep remaining writers behind explicit target routing instead of ad-hoc
direct SQL methods.
## Metadata Write Preflight
+4 -3
View File
@@ -275,11 +275,12 @@ Current progress:
- `metadata.resolve_overrides` now returns `write_plan_evidence`, a ready
`metadata.write.plan` fragment carrying the routine name, object selector
fields, `target.extension_action`, and `next_resolution.params` for
`metadata.saved_state.modules.search`; saved-state module search now resolves
public `object_type`/`object_name` selectors to owner GUIDs when possible.
`metadata.saved_state.modules.search`; the follow-up uses a public object
`ref`/`kind`/`name` and semantic saved-state `layer`, while the search
resolves storage identities internally.
This reduces agent-side field translation while still requiring a concrete
saved-state module route.
- `metadata.saved_state.modules.search` now returns
- With `include_storage=true`, `metadata.saved_state.modules.search` returns
`streams[].write_plan_target`, a concrete module target with `module_ref`,
`file_name`, `stream_index`, and `expected_sha1` for the next
`metadata.write.plan` call.
+60 -2
View File
@@ -62,8 +62,66 @@ independent metadata properties and must be filtered as derived evidence.
The `test2` experiment on 2026-07-15 added a minimal calculation register and
its required chart of calculation types through Designer, then saved the
extension without applying it. The objects remain intentionally `saved_only`
for adapter regression checks.
extension without applying it. That historical fixture was later removed from
the live saved state. The observations below remain decoder evidence, but they
must not be interpreted as proof that the current `upo_test` still contains the
object.
The reproducible fixture requirements are declared in
`config/1c_metadata_kind_fixtures.json`. Validate the declaration offline:
```powershell
python scripts/check_1c_metadata_kind_fixtures.py
```
Probe the current adapter and test base:
```powershell
python scripts/check_1c_metadata_kind_fixtures.py --live
```
Use `--require-ready` only as a strict regression gate after the fixtures have
been loaded with 1C Designer. The checker never creates metadata and never
writes platform SQL. `CalculationRegister` and `Sequence` belong in the saved
`test2` extension. Legacy `Interface` requires a dedicated compatible test
base, supplied explicitly as
`--target-base interface_legacy_base=<base_id>`; it must not be simulated in a
modern configuration by editing `Config`, `ConfigCAS`, or `DBNames`.
At the 2026-07-26 verification point, the `upo_test` server required platform
`8.5.1.1236`. The exact Designer build is now installed at
`C:\Program Files\1cv8\8.5.1.1236\bin\1cv8.exe`; the previous client-version
blocker is closed.
Export the current saved `test2` sources through operating-system integrated
authentication only:
```powershell
.\scripts\export_1c_extension_sources.ps1 `
-OutputDirectory C:\Temp\upo-test-test2-source
```
The export helper verifies the exact executable version, refuses a non-empty
output directory, always passes `/WA+`, and has no user or credential
parameters. It only reads and exports the extension; it does not load, save, or
apply a configuration.
The current workstation account is not mapped to an infobase user on
`wsr\upo_test`, so the server export stops with
`Пользователь ИБ не идентифицирован`. A disposable local file infobase needs a
client license and stops with `Не найдена лицензия`. Therefore provisioning is
still blocked by execution environment, not by adapter code or platform
version. Continue on a licensed workstation under an already authorized
operating-system identity; do not add a user name or password to scripts,
manifests, or reports, and do not bypass the block with direct SQL.
An external structural reference is pinned to
`asosnoviy/go1cover@c7e3ea3d90396d341cb98285c29973bcc0a82eca`,
path `testresourse/cf/designer/src`. It contains examples of
`CalculationRegister`, `Sequence`, and legacy `Interface`. The upstream
repository is GPL-3.0; its files are reference input only and are not vendored
into this project. The pinned source and per-kind paths are declared in the
fixture manifest.
Observed read-only SQL signatures in `ConfigCASSave`:
+179
View File
@@ -0,0 +1,179 @@
# Политика хранилища по слоям конфигурации 1С
Каждая конфигурация имеет собственную политику: основная конфигурация — слой
`base`, каждое расширение — слой `extension:<GUID>`. Подключение основной
конфигурации к хранилищу не означает подключения расширения и наоборот.
Адаптер работает с метаданными 1С только через SQL. Он не считает SQL-таблицы
доказательством нативного захвата объекта в хранилище.
Заявки на захват и явные ручные подтверждения — это служебные данные адаптера,
а не данные 1С. Они хранятся в `/data/onec-repository-locks.json` в named
volume Docker `adapter-1c-data` и переживают пересоздание контейнера. Это
хранилище не используется для вывода о нативном захвате и не требует записи в
какую-либо базу 1С.
## Настройка
В `development_layers` для каждого фактически используемого слоя задаётся
`repository.connection_state`:
```json
{
"base": {
"repository": {"mode": "manual", "connection_state": "configured"},
"support": {"mode": "unknown"}
},
"extension:86d12be8-086c-11f0-925c-0050568c1b38": {
"repository": {"mode": "none", "connection_state": "not_configured"},
"support": {"mode": "unknown"}
}
}
```
Допустимые состояния:
| Состояние | Политика записи |
| --- | --- |
| `not_configured` | Слой не подключён к хранилищу; захват не требуется. Ограничения поддержки сохраняются. |
| `configured` | Подключение известно. В SQL-only режиме нужен запрос и явное подтверждение захвата. |
| `unavailable` | Подключение предполагается, но проверить его нельзя; нужен запрос и явное подтверждение захвата. |
| `unknown` | Не делать предположений: запись в слой блокируется до настройки. |
Для ручного подтверждения всегда передаётся точный объект и явный флаг
`user_confirmed_locked`. Имя пользователя хранилища берётся из
`repository_user` настройки данного слоя. Поэтому при заполненной настройке
его не нужно повторять в каждой заявке или подтверждении. Если клиент всё же
передаёт `confirmed_repository_user`, оно должно совпадать с настроенным
именем. Если имя не настроено, подтверждение попросит его явно.
## Сохранение факта подключения агентом
Агент может сохранить подтверждённый факт в служебной конфигурации адаптера
через `repository.layer.connection.set`. Метод не меняет SQL-базу 1С и требует
явного флага подтверждения.
Основная конфигурация подключена к хранилищу:
```json
{
"method": "repository.layer.connection.set",
"payload": {
"base_id": "neft",
"layer_id": "base",
"connection_state": "configured",
"repository_user": "ivanov",
"confirm_repository_connection_change": true
}
}
```
Расширение точно не подключено:
```json
{
"method": "repository.layer.connection.set",
"payload": {
"base_id": "neft",
"extension_guid": "86d12be8-086c-11f0-925c-0050568c1b38",
"connection_state": "not_configured",
"confirm_repository_connection_change": true
}
}
```
При `not_configured` адаптер устанавливает режим слоя `none`. При
`configured` или `unavailable` новый слой по умолчанию получает ручную
политику захвата. Рекомендуется сразу передать постоянный
`repository_user`: он сохраняется только в конфигурации адаптера для этого
слоя и используется во всех последующих ручных подтверждениях.
## Ручное подтверждение захвата
После `repository.lock.request` клиент должен использовать возвращённый
`next_call` без самостоятельного подбора имён полей. Поля из
`next_call.params` передаются как RPC `payload`; его эквивалент:
```json
{
"method": "repository.lock.confirm",
"payload": {
"base_id": "neft",
"request_id": "rreq-…",
"user_confirmed_locked": true
}
}
```
`objects`, `operation` и `layer_id` не передаются повторно: подтверждение
берёт их из неизменяемой сохранённой заявки. Если имя пользователя сохранено
для слоя, оно тоже не передаётся. Успешный ответ содержит `lock_session_id`;
его необходимо передать в preflight и операцию записи.
Ответ `repository.lock.confirm` содержит готовый `write_context`. Его можно
передать целиком либо верхнеуровневым полем `write_context`, либо алиасом
`repository_lock`; адаптер нормализует поля до проверки захвата:
```json
{
"base_id": "neft",
"target": {"module_id": "Config:<form-guid>.0"},
"repository_lock": {"lock_session_id": "rlock-…", "repository_object": "Document.Имя.Form.ИмяФормы", "layer_id": "base"}
}
```
Если одновременно переданы вложенное и верхнеуровневое значение, они должны
совпадать; иначе возвращается `repository_lock_context_conflict`.
## Проверка
`repository.layers.audit` обнаруживает основную конфигурацию и расширения из
live SQL и выдаёт для каждого слоя состояние хранилища, общий режим поддержки
и безопасное следующее действие:
```json
{
"method": "repository.layers.audit",
"payload": {"base_id": "neft"}
}
```
Ключевые действия в ответе:
- `repository_lock_not_required` — слой явно не подключён к хранилищу;
- `request_and_confirm_repository_capture` — перед записью требуется
подтверждение захвата;
- `configure_repository_connection_state` — слой нельзя менять, пока не
установлен один из статусов подключения.
Проверка поддержки выполняется отдельно от хранилища. Для конкретного объекта
используйте `metadata.write.preflight`: разрешение объекта определяет его
`origin`, и адаптер применяет политику найденного слоя, а не клиентский выбор
слоя.
## Захват формы владельца
Для формы документа, справочника, обработки или отчёта область захвата задаётся
точно, без сворачивания к владельцу:
```json
{
"method": "repository.lock.request",
"payload": {
"base_id": "neft",
"objects": ["Document.тл_Планировщик.Form.ФормаДокументаНовая"],
"operation": "modify"
}
}
```
До создания заявки `repository.lock.plan` и сама заявка разрешают форму через
live SQL, получают GUID формы и сохраняют точную каноническую область. После
ручного подтверждения эта сессия не даёт права на соседние формы или модуль
владельца.
Также принимается `Form.<GUID>`. Для него адаптер проверяет связь формы с
владельцем по live SQL; при отсутствии готовой производной записи владельца
выполняется ограниченный SQL-скан форм поддерживаемых типов владельцев. Если
владелец не найден, планирование и заявка завершаются `not_found`, а не
возвращают ложный `ready`.
+41 -1
View File
@@ -126,7 +126,10 @@ REST and MCP selector-chain smoke tests. The selector-chain smoke also checks,
when a routine name is available from module metadata, that
`metadata.resolve_overrides` returns `write_plan_evidence.next_resolution` for
`metadata.saved_state.modules.search` with the same `base_id` and routine query,
then follows that resolver. If a saved-state stream exposes `write_plan_target`,
plus a public object `ref`/`kind`/`name` and semantic
`layer=base_saved_state|extension_saved_state`; public follow-ups never require
`ConfigSave`, `ConfigCASSave`, or `owner_guid`. The smoke then follows that
resolver. If a saved-state stream exposes `write_plan_target`,
the smoke composes it with `write_plan_evidence.target` and checks the resulting
read-only `metadata.write.plan`; when the test base has no saved-state stream,
that last composition step records `skipped_no_saved_state_target`.
@@ -506,6 +509,23 @@ roles/users can read or write this metadata object". Do not depend on
`metadata.objects.list kind=Role`: some live bases do not expose roles as regular
metadata objects through that route.
For BSP data-access keys, keep the metadata object and concrete data record
separate:
```json
{
"base_id": "upo_test",
"kind": "object",
"object_ref": "Справочники.Номенклатура",
"record_ref": "00112233445566778899AABBCCDDEEFF"
}
```
Here `kind=object` is the `access_keys_query` area, not the metadata kind.
`object_ref` is resolved to the internal BSP SQL number and `record_ref` to the
record id. The same public pair works with `access_object_keys_resolve` and
`access_object_explain`; do not invent `object_sql_number` in MCP code.
For BSP role-right checks, `action=write` means add or modify rights. Read-only
roles must not be returned for `write`; query `action=read` separately when you
need visibility roles. Returned permissions keep `source_fields` from
@@ -634,6 +654,26 @@ Agent rules:
`metadata.form.command_button.write` for the complete form command + visible
button + handler workflow. `code.write` remains a compatibility shortcut for
simple module edits.
- For a metadata object's standard `synonym` or `comment`, call generic
`onec_request` with `method=metadata.object.property.write`, a public
`ref`/`kind` + `name`, and `allow_saved_state_write=true`. Prefer
`execution_mode=plan` first. The adapter resolves GUIDs and serialized paths;
MCP callers must not pass SQL tables or file names. Object rename and adding
a new synonym locale are intentionally unsupported.
- For an existing requisite, tabular section, dimension, or resource, add a
public `member_ref` such as
`Catalog.Номенклатура.Attribute.Артикул`. When only
`member_kind` + `member_name` is used and several identities match, preserve
the adapter's `ambiguous` result and retry with the full member path.
- To add a requisite, call `metadata.object.member.add` through `onec_request`
with a full `template_member_ref` from the same object and the new public
name/synonym. Never invent a GUID, Config table, collection path, or type
descriptor in MCP code; the new requisite inherits type/settings from the
named template.
- The same method adds a tabular-section column when
`template_member_ref` contains the full
`...TabularSection.<name>.Attribute.<column>` path. Keep the complete path so
duplicate checks and generated identity remain scoped to that table part.
- For unresolved module owners, inspect `diagnostics.owner_resolution` and
`counts.owner_scan_limit_hit`; narrow by `kind`/`name`/`guid` or increase
`owner_scan_limit` before falling back to broader searches.
+10
View File
@@ -28,8 +28,18 @@ ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST=
ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED=false
ONEC_ADAPTER_CACHE_DB=/data/adapter-cache.sqlite
# Adapter-owned runtime state (jobs, progress, restart diagnostics). This is
# local SQLite and must not point to a 1C SQL database. Defaults to CACHE_DB.
ONEC_ADAPTER_STATE_DB=/data/adapter-cache.sqlite
# Legacy JSON job store is read once for migration only.
ONEC_ADAPTER_JOB_STORE=/data/adapter-jobs.json
ONEC_ADAPTER_BACKUP_DIR=/data/adapter-apply-backups
ONEC_ADAPTER_WRITE_LEARNING_DIR=/data/adapter-write-learning
ONEC_ADAPTER_JOB_TIMEOUT_SECONDS=240
ONEC_ADAPTER_FULL_TIMEOUT_SECONDS=600
ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS=180
# Run non-stateful background calls in killable child processes.
ONEC_ADAPTER_JOB_PROCESS_ISOLATION=true
# Optional POSIX child-process limits; 0 keeps the platform/container limit.
ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB=0
ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS=0
+39 -1
View File
@@ -223,6 +223,7 @@ Current live methods:
- `metadata.objects.list`
- `metadata.object.get`
- `metadata.object.properties`
- `metadata.object.property.write`
- `metadata.object.decode`
- `metadata.object.parts`
- `metadata.object.modules`
@@ -260,6 +261,13 @@ qualified metadata names such as `Обработка.<Name>` or `Document.<Name>
Client, MCP, and agent code must not add conditions for concrete object names;
the adapter owns generic selector normalization.
Saved-state client calls use the same name-first selectors together with
`layer=base_saved_state|extension_saved_state`. SQL tables, file names, GUID
owners, and module handles are diagnostic continuations exposed only with
`include_storage=true`. Every public RPC follow-up is shaped as
`{"method": "...", "params": {...}}`; `payload` is not the arguments field of
`next_call` or `next_resolution`.
Layer write policy:
- `Config` and `ConfigCAS` are **active-applied** and must be treated as read-only in adapter workflows.
@@ -293,6 +301,26 @@ metadata kind. It selects a kind-specific SQL decoder for `Configuration`,
semantic profile. XML exports are analysis evidence only and are never a
runtime source for this method or any other adapter method.
`metadata.object.property.write` is the name-first saved-state writer for the
standard identity properties `synonym` and `comment`. Pass a public object
`ref` or `kind` + `name`. For an existing attribute, tabular section,
dimension, or resource, also pass `member_ref` or `member_kind` +
`member_name`; the adapter resolves the exact parent/member GUIDs and
serialized tree path internally. The method supports `plan`, `apply`,
`apply_and_verify`, and `apply_and_rollback`, requires explicit saved-state
gates, and never writes active `Config`/`ConfigCAS`. Renaming an object and
member, adding/removing collection items, and adding a new synonym locale
remain intentionally disabled.
`metadata.object.member.add` adds one new object requisite or tabular-section
column (`Attribute`) by cloning an existing attribute in the same collection.
The caller passes only
`template_member_ref`, `new_member_name`, and optionally synonym/comment; the
adapter generates the GUID, preserves the template's type/settings, appends to
the exact declared collection, and verifies the new identity after apply.
Arbitrary type construction and deletion are not supported by this first
structural route.
Managed form bodies in base `Config` are resolved from the public form GUID to
the sibling `<guid>.0` SQL payload. Command-bar buttons expose public command
names when their SQL binding points to a common command or a recognized
@@ -468,7 +496,17 @@ capabilities).
`metadata.adapter.audit` reports recognized metadata kinds, public kind counts,
missing supported kinds when `include_missing=true`, and unmapped DBNames roles
when `include_unmapped=true`.
when `include_unmapped=true`. Missing entries are marked
`presence_status=supported_absent_in_selected_base`; this is a statement about
the selected infobase, not a claim that the adapter lacks that kind.
Use `python scripts/check_1c_metadata_kind_fixtures.py --live` from the
repository root to check the reproducible rare-kind fixtures. The checker does
not write SQL or create metadata. The fixture manifest pins the exact Designer
version and external reference commit. The companion
`scripts/export_1c_extension_sources.ps1` performs a read-only `test2` source
export with operating-system integrated authentication and exposes no
infobase-user or credential arguments.
`codec.decode` and `codec.encode` are low-level lossless helpers. A no-op encode
from a live source keeps the original bytes exactly; modified text/tree payloads
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -74,12 +74,7 @@ function openEditor(base = null) {
$("baseId").value = base?.base_id || ""; $("server").value = base?.server || "";
$("database").value = base?.database || ""; $("user").value = base?.user || "";
$("password").value = ""; $("passwordEnv").value = base?.password_env || "";
$("repositoryEnabled").checked = Boolean(base?.repository);
$("repositoryBackend").value = base?.repository?.backend || "direct";
$("repositoryLayer").value = base?.repository?.layer || "base";
$("repositoryLockMode").value = base?.repository?.lock_mode || "automatic";
$("repositoryBridgeId").value = base?.repository?.bridge_id || "";
$("repositoryUser").value = base?.repository?.repository_user || "";
$("developmentLayers").value = JSON.stringify(base?.development_layers || { base: { repository: { mode: "none", connection_state: "not_configured" }, support: { mode: "unknown" } } }, null, 2);
$("passwordHint").textContent = base?.has_password ? "Пароль уже задан. Оставьте пустым, чтобы сохранить текущий." : "Задайте пароль или переменную окружения.";
$("editor").showModal(); setTimeout(() => $("baseId").focus(), 30);
}
@@ -88,7 +83,10 @@ async function save(event) {
event.preventDefault();
if (!$("baseForm").reportValidity()) return;
const original = $("originalId").value;
const payload = { base_id: $("baseId").value.trim(), server: $("server").value.trim(), database: $("database").value.trim(), user: $("user").value.trim(), password: $("password").value, password_env: $("passwordEnv").value.trim(), repository: $("repositoryEnabled").checked ? { backend: $("repositoryBackend").value, layer: $("repositoryLayer").value, lock_mode: $("repositoryLockMode").value, bridge_id: $("repositoryBridgeId").value.trim(), repository_user: $("repositoryUser").value.trim() } : { enabled: false } };
let developmentLayers;
try { developmentLayers = JSON.parse($("developmentLayers").value || "{}"); }
catch (_) { notice("Слои разработки должны быть корректным JSON.", true); return; }
const payload = { base_id: $("baseId").value.trim(), server: $("server").value.trim(), database: $("database").value.trim(), user: $("user").value.trim(), password: $("password").value, password_env: $("passwordEnv").value.trim(), development_layers: developmentLayers, repository: { enabled: false } };
$("saveButton").disabled = true;
try {
await request(original ? `/admin/api/bases/${encodeURIComponent(original)}` : "/admin/api/bases", { method: original ? "PUT" : "POST", body: JSON.stringify(payload) });
+1 -6
View File
@@ -43,12 +43,7 @@
<label>SQL-логин<input id="user" required autocomplete="username" placeholder="onec_reader"></label>
<label class="wide">Пароль<input id="password" type="password" autocomplete="new-password" placeholder="Оставьте пустым, чтобы не менять"><small id="passwordHint">Пароль сохраняется в защищённом runtime-файле и никогда не отображается.</small></label>
<label class="wide">Или переменная окружения<input id="passwordEnv" placeholder="ONEC_SQL_PASSWORD_UPO_TEST"><small>Если заполнено, имеет приоритет над введённым паролем.</small></label>
<label class="wide"><input id="repositoryEnabled" type="checkbox"> Конфигурация подключена к хранилищу</label>
<label>Доступ к хранилищу<select id="repositoryBackend"><option value="direct">Прямой</option><option value="karman_bridge">Через Карман</option></select></label>
<label>Слой<select id="repositoryLayer"><option value="base">Основная конфигурация</option><option value="extension">Расширение</option></select></label>
<label>Захват объектов<select id="repositoryLockMode"><option value="manual">Вручную пользователем (SQL-only)</option><option value="automatic" disabled>Через внешнюю 1С (следующая версия)</option></select></label>
<label>ID моста (из настройки)<input id="repositoryBridgeId" placeholder="Необязательно"></label>
<label>Пользователь хранилища<input id="repositoryUser" autocomplete="off" placeholder="Например, adm"></label>
<label class="wide">Слои разработки (JSON)<textarea id="developmentLayers" rows="12" spellcheck="false" placeholder='{"base":{"repository":{"mode":"manual"},"support":{"mode":"unknown"}}}'></textarea><small>Основная конфигурация задаётся ключом base, каждое расширение — отдельным ключом extension:&lt;GUID&gt;. repository.mode: none/manual/automatic/unknown; support.mode: none/editable/locked/rules/unknown.</small></label>
</div>
<div class="dialog-actions"><button value="cancel" class="secondary">Отмена</button><button id="saveButton" value="default" class="primary">Сохранить</button></div>
</form>
+266 -6
View File
@@ -16,6 +16,164 @@ paths:
responses:
"200":
description: Connector health.
/methods:
get:
operationId: listAdapterMethods
summary: Runtime adapter method registry
description: Returns every supported RPC method, transport, description, selector capabilities, and input schema known by the running adapter.
parameters:
- name: method
in: query
required: false
schema:
type: string
description: Optional exact method name.
responses:
"200":
description: Runtime registry generated from the adapter method definitions.
content:
application/json:
schema:
$ref: "#/components/schemas/AdapterMethodsResponse"
/rpc:
post:
operationId: callAdapterMethod
summary: Universal adapter RPC
description: Calls any method advertised by GET /methods. The selected method's input_schema from /methods defines payload.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AdapterRpcRequest"
responses:
"200":
description: Method-specific adapter response. Application errors are returned as structured JSON statuses.
content:
application/json:
schema:
type: object
additionalProperties: true
/extensions:
get:
operationId: listExtensions
parameters:
- $ref: "#/components/parameters/BaseId"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/object/templates:
post:
operationId: listObjectTemplates
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/object/template-details:
post:
operationId: getObjectTemplateDetails
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/object/form-details:
post:
operationId: getObjectFormDetails
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/object/commands:
post:
operationId: listObjectCommands
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/object/special-details:
post:
operationId: getObjectSpecialDetails
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/module-owner-cache/prune:
post:
operationId: pruneModuleOwnerCache
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/code/search:
post:
operationId: searchCode
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/code/read:
post:
operationId: readCode
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/templates/bindings:
post:
operationId: getTemplateBindings
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/diagnostics/call-chain:
post:
operationId: diagnoseCallChain
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/access/users/search:
post:
operationId: searchAccessUsers
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/access/object/roles:
post:
operationId: getAccessObjectRoles
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/access/object/subjects:
post:
operationId: getAccessObjectSubjects
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/access/rls/discover:
post:
operationId: discoverAccessRls
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/kinds:
get:
operationId: getMetadataKinds
@@ -1734,7 +1892,11 @@ paths:
type: string
enum: [group, user_set, object, set, all]
default: all
description: BSP access key area to page through.
description: BSP access key area to page through; this is not a 1C metadata kind.
area:
type: string
enum: [group, user_set, object, set, all]
description: Alias of kind.
group:
type: string
description: Filter group access keys by normalized group id.
@@ -1744,9 +1906,21 @@ paths:
user_set:
type: string
description: Filter user-set access keys by normalized user set id.
object_ref:
type: string
description: Public 1C metadata object ref, for example Справочники.Номенклатура.
record_ref:
type: string
description: Concrete 1C application-data record reference.
object:
type: string
description: Filter object access keys by normalized object id.
description: Legacy raw BSP object filter; prefer object_ref plus record_ref.
object_id:
type: string
description: Legacy raw BSP record id; prefer record_ref.
object_sql_number:
type: integer
description: Legacy physical DBNames number; prefer object_ref.
access_set:
type: string
description: Filter access-set keys by normalized access set id.
@@ -1795,9 +1969,21 @@ paths:
properties:
base_id:
type: string
object_ref:
type: string
description: Public 1C metadata object ref, for example Справочники.Номенклатура.
record_ref:
type: string
description: Concrete 1C application-data record reference.
object:
type: string
description: Optional normalized object ref filter.
description: Legacy raw BSP object filter; prefer object_ref plus record_ref.
object_id:
type: string
description: Legacy raw BSP record id; prefer record_ref.
object_sql_number:
type: integer
description: Legacy physical DBNames number; prefer object_ref.
access_key:
type: string
description: Optional access key filter.
@@ -1836,15 +2022,21 @@ paths:
properties:
base_id:
type: string
object_ref:
type: string
description: Public 1C metadata object ref, for example Справочники.Номенклатура.
record_ref:
type: string
description: Concrete 1C application-data record reference.
object:
type: string
description: Normalized object ref from access_object_keys.object.
description: Legacy raw BSP object filter; prefer object_ref plus record_ref.
object_id:
type: string
description: Data record id from access_object_keys.object_id.
description: Legacy raw BSP record id; prefer record_ref.
object_sql_number:
type: integer
description: Optional SQL metadata number to narrow object key rows.
description: Legacy physical DBNames number; prefer object_ref.
access_key:
type: string
description: Access key id to explain directly.
@@ -2020,6 +2212,27 @@ paths:
"200":
description: Risk findings for role -> profile -> access group -> user audit chains.
components:
requestBodies:
AdapterMethodPayload:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
examples:
selectorByName:
value:
base_id: <base_id>
ref: Документы.<ИмяОбъекта>
responses:
AdapterMethodResponse:
description: Method-specific adapter response with a structured status.
content:
application/json:
schema:
type: object
additionalProperties: true
parameters:
BaseId:
name: base_id
@@ -2033,6 +2246,53 @@ components:
type: http
scheme: bearer
schemas:
AdapterRpcRequest:
type: object
required: [method]
additionalProperties: false
properties:
method:
type: string
minLength: 1
description: Exact method name advertised by GET /methods.
payload:
type: object
default: {}
additionalProperties: true
description: Method-specific input. Use the matching input_schema returned by GET /methods.
AdapterMethod:
type: object
required: [name, transport, description]
additionalProperties: true
properties:
name:
type: string
transport:
type: string
description:
type: string
selector_capabilities:
type: object
additionalProperties: true
input_schema:
type: object
additionalProperties: true
AdapterMethodsResponse:
type: object
required: [schema, contract_version, methods, count]
properties:
schema:
type: string
const: onec_adapter_methods.v1
contract_version:
type: string
methods:
type: array
items:
$ref: "#/components/schemas/AdapterMethod"
count:
type: integer
minimum: 0
MetadataKind:
type: string
enum:
+308 -42
View File
@@ -15,6 +15,14 @@ from typing import Any
METHOD_STATUS = "repository.status"
SQL_LOCK_EVIDENCE = {
"native_lock_state": "unknown",
"native_lock_verification": "not_available_via_infobase_sql",
"excluded_sources": {
"_ConfigChngR": "exchange_plan_change_registration",
"_ConfigChngR_ExtProps": "exchange_plan_changed_file_details",
},
}
METHOD_LOCK_PLAN = "repository.lock.plan"
METHOD_LOCK_REQUEST = "repository.lock.request"
METHOD_LOCK_REQUEST_STATUS = "repository.lock.request.status"
@@ -29,6 +37,9 @@ METHOD_COMMIT = "repository.commit"
METHODS = {METHOD_STATUS, METHOD_LOCK_PLAN, METHOD_LOCK_REQUEST, METHOD_LOCK_REQUEST_STATUS, METHOD_LOCK_REQUEST_CANCEL, METHOD_LOCK, METHOD_CONFIRM, METHOD_VERIFY, METHOD_CLOSE, METHOD_UNLOCK, METHOD_COMMIT_PLAN, METHOD_COMMIT}
SUPPORTED_BACKENDS = {"direct", "karman_bridge"}
SUPPORTED_LOCK_MODES = {"automatic", "manual"}
SUPPORTED_REPOSITORY_MODES = {"none", "manual", "automatic", "unknown"}
SUPPORTED_REPOSITORY_CONNECTION_STATES = {"not_configured", "configured", "unavailable", "unknown"}
SUPPORTED_SUPPORT_MODES = {"none", "editable", "locked", "rules", "unknown"}
_BASE_LOCKS: dict[str, threading.Lock] = {}
_BASE_LOCKS_GUARD = threading.Lock()
_STATE_LOCK = threading.RLock()
@@ -66,13 +77,60 @@ def _load_json_map(env_name: str, file_env_name: str) -> tuple[dict[str, Any] |
return value, None
def repository_config(base_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
def development_layer_id(payload: dict[str, Any] | None = None) -> str:
value = payload if isinstance(payload, dict) else {}
target = value.get("target") if isinstance(value.get("target"), dict) else {}
origin = target.get("origin") if isinstance(target.get("origin"), dict) else {}
origin_extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {}
extension_guid = str(value.get("extension_guid") or target.get("extension_guid") or origin_extension.get("guid") or "").strip().lower()
layer_id = str(value.get("layer_id") or target.get("layer_id") or "").strip().lower()
if extension_guid:
return f"extension:{extension_guid}"
if layer_id.startswith("extension:"):
return layer_id
return "base"
def _base_settings(base_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
values, error = _load_json_map("ONEC_SQL_BASES_JSON", "ONEC_SQL_BASES_JSON_FILE")
base_item = values.get(base_id) if values and isinstance(values.get(base_id), dict) else None
item = base_item.get("repository") if isinstance(base_item, dict) else None
if base_item is not None:
return base_item, None
return None, error or {"status": "not_configured", "message": f"No SQL configuration for base_id '{base_id}'."}
def development_layer_config(base_id: str, layer_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
base_item, base_error = _base_settings(base_id)
if base_error:
return None, base_error
layers = base_item.get("development_layers") if isinstance(base_item.get("development_layers"), dict) else None
if layers is not None:
item = layers.get(layer_id)
if not isinstance(item, dict):
return None, {"status": "layer_not_configured", "message": f"No development layer configuration for '{layer_id}' in base_id '{base_id}'."}
return dict(item), None
if layer_id == "base" and isinstance(base_item.get("repository"), dict):
return {"repository": dict(base_item["repository"]), "legacy": True}, None
return None, {"status": "layer_not_configured", "message": f"No development layer configuration for '{layer_id}' in base_id '{base_id}'."}
def repository_config(base_id: str, layer_id: str = "base") -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
base_item, _ = _base_settings(base_id)
layer, layer_error = development_layer_config(base_id, layer_id)
if layer_error is None:
item = layer.get("repository")
if item is None:
return {"mode": "unknown", "lock_mode": "manual", "layer_id": layer_id, "layer": layer_id}, None
else:
item = None
if isinstance(base_item, dict) and isinstance(base_item.get("development_layers"), dict):
return None, layer_error
# Legacy repository-only configuration remains readable for the base layer.
if item is None and layer_id == "base" and isinstance(base_item, dict):
item = base_item.get("repository")
if item is None:
values, repository_error = _load_json_map("ONEC_REPOSITORY_BASES_JSON", "ONEC_REPOSITORY_BASES_JSON_FILE")
if repository_error and error:
if repository_error and layer_error:
return None, repository_error
item = values.get(base_id) if values else None
if item is None:
@@ -80,9 +138,21 @@ def repository_config(base_id: str) -> tuple[dict[str, Any] | None, dict[str, An
if not isinstance(item, dict):
return None, {"status": "invalid_config", "message": f"Repository configuration for '{base_id}' must be an object."}
configured = dict(item)
configured["mode"] = str(configured.get("mode") or configured.get("lock_mode") or "automatic").strip().casefold()
if configured["mode"] not in SUPPORTED_REPOSITORY_MODES:
return None, {"status": "invalid_config", "message": "repository mode must be none, manual, automatic, or unknown."}
default_connection_state = "unknown" if configured["mode"] == "unknown" else ("not_configured" if configured["mode"] == "none" else "configured")
configured["connection_state"] = str(configured.get("connection_state") or configured.get("repository_connection") or configured.get("connection") or default_connection_state).strip().casefold()
if configured["connection_state"] not in SUPPORTED_REPOSITORY_CONNECTION_STATES:
return None, {"status": "invalid_config", "message": "repository connection_state must be not_configured, configured, unavailable, or unknown."}
if configured["mode"] == "unknown":
return {"mode": "unknown", "lock_mode": "manual", "connection_state": configured["connection_state"], "layer_id": layer_id, "layer": layer_id}, None
if configured["mode"] == "none":
return {"mode": "none", "lock_mode": "manual", "connection_state": configured["connection_state"], "layer_id": layer_id, "layer": layer_id}, None
configured["backend"] = str(configured.get("backend") or "direct").strip().casefold()
configured["layer"] = str(configured.get("layer") or "base").strip().casefold()
configured["lock_mode"] = str(configured.get("lock_mode") or "automatic").strip().casefold()
configured["layer_id"] = layer_id
configured["layer"] = layer_id
configured["lock_mode"] = configured["mode"]
if configured["backend"] not in SUPPORTED_BACKENDS:
return None, {"status": "invalid_config", "message": "repository backend must be direct or karman_bridge."}
if configured["lock_mode"] not in SUPPORTED_LOCK_MODES:
@@ -105,6 +175,25 @@ def repository_config(base_id: str) -> tuple[dict[str, Any] | None, dict[str, An
return configured, None
def manual_confirmation_required(config: dict[str, Any]) -> bool:
"""Whether SQL-only operation needs the user's exact Configurator capture confirmation."""
if config.get("mode") in {"none", "unknown"} or config.get("connection_state") == "not_configured":
return False
return config.get("connection_state") == "unavailable" or config.get("lock_mode") == "manual" or not external_1c_enabled()
def manual_confirmation_next_call(base_id: str, request_id: str) -> dict[str, Any]:
"""Machine-readable continuation; clients must not infer confirmation keys."""
return {
"method": METHOD_CONFIRM,
"params": {
"base_id": base_id,
"request_id": request_id,
"user_confirmed_locked": True,
},
}
def _secret(config: dict[str, Any], field: str) -> str:
env_name = str(config.get(f"{field}_env") or "").strip()
return os.environ.get(env_name, "") if env_name else ""
@@ -112,9 +201,12 @@ def _secret(config: dict[str, Any], field: str) -> str:
def _public_config(config: dict[str, Any]) -> dict[str, Any]:
return {
"mode": config.get("mode") or config.get("lock_mode"),
"layer_id": config.get("layer_id") or config.get("layer"),
"backend": config.get("backend"),
"layer": config.get("layer"),
"lock_mode": config.get("lock_mode"),
"connection_state": config.get("connection_state"),
"adapter_access_mode": "sql_only" if not external_1c_enabled() else "sql_and_external_1c",
"automatic_repository_operations_available": external_1c_enabled(),
"endpoint": config.get("endpoint"),
@@ -134,6 +226,24 @@ def _public_config(config: dict[str, Any]) -> dict[str, Any]:
}
def public_development_layers(base_id: str) -> dict[str, Any]:
base_item, error = _base_settings(base_id)
if error or not isinstance(base_item, dict):
return {}
layers = base_item.get("development_layers") if isinstance(base_item.get("development_layers"), dict) else {}
result: dict[str, Any] = {}
for layer_id, layer in layers.items():
if not isinstance(layer, dict):
continue
repository, repository_error = repository_config(base_id, str(layer_id))
support = layer.get("support") if isinstance(layer.get("support"), dict) else {"mode": "unknown"}
result[str(layer_id)] = {
"repository": _public_config(repository) if repository and not repository_error else {"status": "invalid", "problem": repository_error},
"support": {"mode": str(support.get("mode") or "unknown"), "rules": dict(support.get("rules") or {}) if isinstance(support.get("rules"), dict) else {}},
}
return result
def _infobase_args(config: dict[str, Any]) -> list[str]:
infobase = config["infobase"]
if infobase.get("file"):
@@ -311,24 +421,38 @@ def lock_plan(payload: dict[str, Any]) -> dict[str, Any]:
"schema": "onec_repository_lock_plan.v1",
"method": METHOD_LOCK_PLAN,
"status": "ready" if not warnings else "needs_confirmation",
"scope_status": "resolved",
"operation": operation,
"requested_objects": [str(x) for x in (payload.get("objects") or [payload.get("object") or payload.get("ref") or payload.get("path")])],
"lock_objects": objects,
"warnings": warnings,
"native_lock_state": "unknown",
"native_lock_verification": "not_available_via_infobase_sql" if not external_1c_enabled() else "not_checked",
"capture_required": None,
"sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [],
}
base_id = str(payload.get("base_id") or "").strip()
if base_id:
config, config_error = repository_config(base_id)
layer_id = development_layer_id(payload)
result["layer_id"] = layer_id
config, config_error = repository_config(base_id, layer_id)
if config_error:
result["repository_problem"] = config_error
elif config.get("lock_mode") == "manual":
elif config.get("mode") == "unknown" or config.get("connection_state") == "unknown":
result["workflow"] = "blocked"
result["status"] = "blocked_repository_connection_unknown"
result["next_method"] = None
elif config.get("mode") == "none" or config.get("connection_state") == "not_configured":
result["workflow"] = "not_required"
result["next_method"] = None
elif manual_confirmation_required(config):
result["workflow"] = "manual"
result["next_method"] = METHOD_CONFIRM
result["next_method"] = METHOD_LOCK_REQUEST if not external_1c_enabled() else METHOD_CONFIRM
result["user_action"] = {
"action": "lock_in_configurator",
"action": "confirm_lock_in_configurator",
"base_id": base_id,
"objects": objects,
"message": "Захватите перечисленные объекты в Конфигураторе, затем явно подтвердите тот же список через repository.lock.confirm.",
"message": "SQL информационной базы не показывает нативное состояние захвата. Убедитесь в Конфигураторе, что перечисленные объекты уже захвачены указанным пользователем хранилища, и явно подтвердите тот же список через repository.lock.confirm.",
}
else:
result["workflow"] = "automatic"
@@ -338,36 +462,57 @@ def lock_plan(payload: dict[str, Any]) -> dict[str, Any]:
def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
layer_id = development_layer_id(payload)
config, error = repository_config(base_id, layer_id)
if error:
return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, **error}
plan = lock_plan(payload)
if plan.get("workflow") == "not_required":
return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, "layer_id": layer_id, "status": "not_required", "plan": plan}
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
plan["status"] = "ready"
if plan.get("status") != "ready":
return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, "status": "blocked", "plan": plan}
configured_repository_user = str(config.get("repository_user") or "").strip()
requested_repository_user = str(payload.get("confirmed_repository_user") or payload.get("repository_user") or "").strip()
if requested_repository_user and configured_repository_user and requested_repository_user.casefold() != configured_repository_user.casefold():
return {
"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST,
"base_id": base_id, "layer_id": layer_id, "status": "blocked",
"error": "repository_user_mismatch", "expected_repository_user": configured_repository_user,
}
repository_user = requested_repository_user or configured_repository_user
request_id = "rreq-" + uuid.uuid4().hex
with _STATE_LOCK:
state = _read_state()
state.setdefault("requests", {})[request_id] = {
"base_id": base_id,
"layer": str(config.get("layer") or "base"),
"layer": layer_id,
"layer_id": layer_id,
"backend": config.get("backend"),
"operation": plan.get("operation"),
"objects": plan["lock_objects"],
"created_at": time.time(),
"status": "pending_user_lock",
"execution": "manual",
"repository_user": repository_user,
"sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [],
}
_audit(state, "lock_request_created", request_id=request_id, base_id=base_id, objects=plan["lock_objects"])
_write_state(state)
return {
"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST,
"base_id": base_id, "status": "pending_user_lock", "request_id": request_id,
"base_id": base_id, "layer_id": layer_id, "status": "pending_user_lock", "request_id": request_id,
"objects": plan["lock_objects"], "automatically_locked": False,
"user_action": "Захватите перечисленные объекты в Конфигураторе и подтвердите заявку через repository.lock.confirm.",
"sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [],
"native_lock_state": "unknown",
"native_lock_verification": "not_available_via_infobase_sql",
"capture_required": None,
"repository_user": repository_user or None,
"repository_user_source": "layer_connection_setting" if configured_repository_user and not requested_repository_user else ("request" if requested_repository_user else "not_configured"),
"user_action": "Убедитесь в Конфигураторе, что перечисленные объекты захвачены вами под настроенным пользователем хранилища, и явно подтвердите захват через repository.lock.confirm.",
"next_method": METHOD_CONFIRM,
"next_call": manual_confirmation_next_call(base_id, request_id),
}
@@ -376,7 +521,11 @@ def lock_request_status(payload: dict[str, Any]) -> dict[str, Any]:
request = (_read_state().get("requests") or {}).get(request_id)
if not isinstance(request, dict):
return {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": "not_found", "request_id": request_id}
return {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": request.get("status"), "request_id": request_id, "request": request}
result = {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": request.get("status"), "request_id": request_id, "request": request}
if request.get("status") == "pending_user_lock":
result["next_method"] = METHOD_CONFIRM
result["next_call"] = manual_confirmation_next_call(str(request.get("base_id") or ""), request_id)
return result
def cancel_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
@@ -449,25 +598,34 @@ def admin_state(base_id: str = "") -> dict[str, Any]:
def status(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
layer_id = development_layer_id(payload)
config, error = repository_config(base_id, layer_id)
if error:
return {"schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id, "connected": False, **error}
result: dict[str, Any] = {
"schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id,
"schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id, "layer_id": layer_id,
"status": "configured", "connected": True, "available": None, "repository": _public_config(config),
"sql_lock_evidence": SQL_LOCK_EVIDENCE,
}
if config.get("mode") == "none" or config.get("connection_state") == "not_configured":
result.update({"status": "repository_not_connected", "connected": False, "available": None})
return result
if config.get("mode") == "unknown" or config.get("connection_state") == "unknown":
result.update({"status": "repository_connection_unknown", "connected": False, "available": None})
return result
if manual_confirmation_required(config):
result.update({"status": "manual_confirmation_required", "connected": False, "available": False if config.get("connection_state") == "unavailable" else None})
if bool(payload.get("probe")):
result["probe"] = {"status": "not_applicable", "message": "Manual SQL-only workflow does not establish a native repository connection."}
return result
if not bool(payload.get("probe")):
return result
if not external_1c_enabled():
result["status"] = "sql_only"
result["connected"] = False
result["available"] = None
result["probe"] = {"status": "not_supported", "message": "External 1C access is disabled in this SQL-only adapter version."}
return result
if config.get("lock_mode") == "manual":
result["status"] = "manual_workflow"
result["available"] = None
result["probe"] = {"status": "not_applicable", "message": "Manual lock mode does not require Designer or a repository runner. Use repository.lock.plan."}
return result
timeout_seconds = int(payload.get("timeout_seconds") or 60)
executed = _execute_repository(base_id, config, "report", timeout_seconds)
result["probe"] = executed
@@ -478,10 +636,11 @@ def status(payload: dict[str, Any]) -> dict[str, Any]:
def lock(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
layer_id = development_layer_id(payload)
config, error = repository_config(base_id, layer_id)
if error:
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, **error}
if config.get("lock_mode") == "manual":
if manual_confirmation_required(config):
return {
"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id,
"status": "manual_action_required", "plan": lock_plan(payload), "next_method": METHOD_CONFIRM,
@@ -502,17 +661,18 @@ def lock(payload: dict[str, Any]) -> dict[str, Any]:
session_id = "rlock-" + uuid.uuid4().hex
state = _read_state()
sessions = state.setdefault("sessions", {})
sessions[session_id] = {"base_id": base_id, "layer": layer, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
sessions[session_id] = {"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
_write_state(state)
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "acquired", "lock_session_id": session_id, "acquired": plan["lock_objects"], "backend": config.get("backend"), "execution": executed}
def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
layer_id = development_layer_id(payload)
config, error = repository_config(base_id, layer_id)
if error:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, **error}
if config.get("lock_mode") != "manual":
if not manual_confirmation_required(config):
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "manual_lock_mode_required"}
if not external_1c_enabled() and not str(payload.get("request_id") or "").strip():
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_required", "next_method": METHOD_LOCK_REQUEST}
@@ -525,31 +685,73 @@ def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_not_pending", "request_id": request_id}
effective_payload = dict(payload)
if isinstance(request, dict):
layer_id = str(request.get("layer_id") or request.get("layer") or "base")
config, error = repository_config(base_id, layer_id)
if error:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, **error}
if not manual_confirmation_required(config):
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "manual_lock_mode_required"}
effective_payload["objects"] = [str(item) for item in request.get("objects") or []]
effective_payload["layer_id"] = layer_id
effective_payload["sql_resolution"] = request.get("sql_resolution") if isinstance(request.get("sql_resolution"), list) else []
if payload.get("user_confirmed_locked") is not True:
response: dict[str, Any] = {
"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM,
"base_id": base_id, "layer_id": layer_id, "status": "confirmation_required",
"error": "user_confirmed_locked_required",
"required_fields": ["base_id", "request_id", "user_confirmed_locked"],
"next_call": manual_confirmation_next_call(base_id, request_id),
}
if isinstance(request, dict):
response.update({
"request_id": request_id,
"objects": list(request.get("objects") or []),
"repository_user": str(request.get("repository_user") or config.get("repository_user") or "") or None,
"sql_resolution": effective_payload["sql_resolution"],
})
return response
plan = lock_plan(effective_payload)
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
plan["status"] = "ready"
if plan.get("status") != "ready":
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "plan": plan}
if payload.get("user_confirmed_locked") is not True:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "confirmation_required", "plan": plan}
requested_repository_user = str(payload.get("confirmed_repository_user") or payload.get("repository_user") or "").strip()
expected_repository_user = str(config.get("repository_user") or "").strip()
request_repository_user = str(request.get("repository_user") or "").strip() if isinstance(request, dict) else ""
confirmed_repository_user = requested_repository_user or request_repository_user or expected_repository_user
if not confirmed_repository_user:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "confirmation_required", "error": "repository_user_confirmation_required", "plan": plan}
if expected_repository_user and confirmed_repository_user.casefold() != expected_repository_user.casefold():
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "repository_user_mismatch", "expected_repository_user": expected_repository_user}
session_id = "rlock-" + uuid.uuid4().hex
state.setdefault("sessions", {})[session_id] = {
"base_id": base_id, "layer": str(config.get("layer") or "base"), "backend": config.get("backend"),
"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"),
"objects": plan["lock_objects"], "created_at": time.time(), "status": "manual_confirmed",
"verification": "user_confirmation_only", "automatically_verified": False,
"repository_user": confirmed_repository_user,
**({"request_id": request_id} if request_id else {}),
}
if isinstance(request, dict):
request["status"] = "confirmed_by_user"
request["confirmed_at"] = time.time()
request["lock_session_id"] = session_id
_audit(state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"])
_audit(state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"], repository_user=confirmed_repository_user)
_write_state(state)
return {
"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id,
"status": "manual_confirmed", "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"],
"status": "manual_confirmed", "layer_id": layer_id, "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"],
"capture_state": "manually_confirmed",
"native_lock_state": "unknown",
"native_lock_verification": "not_available_via_infobase_sql",
"automatically_verified": False,
"repository_user": confirmed_repository_user,
"write_context": {
"lock_session_id": session_id,
"repository_object": plan["lock_objects"][0] if len(plan["lock_objects"]) == 1 else None,
"layer_id": layer_id,
"locked_objects": plan["lock_objects"],
"instruction": "Copy lock_session_id into metadata.write.preflight and code.write. For one object, repository_object is supplied for clients that do not resolve module ownership themselves.",
},
"warning": "Адаптер принял явное подтверждение пользователя, но не проверял захват через API хранилища.",
}
@@ -604,20 +806,32 @@ def close_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
def write_gate(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
layer_id = development_layer_id(payload)
config, error = repository_config(base_id, layer_id)
if error and error.get("status") == "not_configured":
return {"required": False, "allowed": True, "status": "not_configured"}
return {"required": False, "allowed": True, "status": "not_configured", "layer_id": layer_id}
if error:
return {"required": True, "allowed": False, "status": "blocked_repository_configuration", "problem": error}
return {"required": True, "allowed": False, "status": "blocked_repository_configuration", "layer_id": layer_id, "problem": error}
if config.get("mode") == "none" or config.get("connection_state") == "not_configured":
return {"required": False, "allowed": True, "status": "repository_not_connected", "layer_id": layer_id}
if config.get("mode") == "unknown" or config.get("connection_state") == "unknown":
return {"required": True, "allowed": False, "status": "blocked_repository_connection_unknown", "layer_id": layer_id}
session_id = str(payload.get("lock_session_id") or "").strip()
if not session_id:
return {
"required": True, "allowed": False, "status": "needs_repository_lock",
"backend": config.get("backend"), "next_method": METHOD_LOCK_PLAN,
"backend": config.get("backend"), "layer_id": layer_id, "next_method": METHOD_LOCK_PLAN,
}
session = (_read_state().get("sessions") or {}).get(session_id)
if not isinstance(session, dict) or session.get("base_id") != base_id or session.get("status") not in {"acquired", "manual_confirmed"}:
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "lock_session_id": session_id}
if not isinstance(session, dict):
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_session_not_found", "layer_id": layer_id, "lock_session_id": session_id}
if session.get("base_id") != base_id:
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_session_base_mismatch", "layer_id": layer_id, "lock_session_id": session_id, "session_base_id": session.get("base_id")}
session_layer_id = str(session.get("layer_id") or session.get("layer") or "base")
if session_layer_id != layer_id:
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_layer_mismatch", "layer_id": layer_id, "session_layer_id": session_layer_id, "lock_session_id": session_id}
if session.get("status") not in {"acquired", "manual_confirmed"}:
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_session_inactive", "layer_id": layer_id, "lock_session_id": session_id, "session_status": session.get("status")}
target = payload.get("target") if isinstance(payload.get("target"), dict) else {}
requested = ""
for value in (
@@ -641,15 +855,67 @@ def write_gate(payload: dict[str, Any]) -> dict[str, Any]:
if requested.casefold() not in {item.casefold() for item in locked}:
return {
"required": True, "allowed": False, "status": "blocked_repository_scope_mismatch",
"lock_session_id": session_id, "requested_object": requested, "locked_objects": locked,
"error": "lock_object_mismatch", "lock_session_id": session_id, "requested_object": requested, "locked_objects": locked,
}
return {
"required": True, "allowed": True, "status": "ready", "backend": config.get("backend"),
"required": True, "allowed": True, "status": "ready", "backend": config.get("backend"), "layer_id": layer_id,
"lock_session_id": session_id, "requested_object": requested, "objects": locked,
"verification": "automatic" if session.get("status") == "acquired" else "user_confirmation_only",
}
def support_gate(payload: dict[str, Any]) -> dict[str, Any]:
"""Evaluate the independently configured support policy for one exact target.
Repository scope may collapse a child to its development owner. Support scope
intentionally does not: a form or another child can have its own support rule.
"""
base_id = str(payload.get("base_id") or "").strip()
layer_id = development_layer_id(payload)
base_item, base_error = _base_settings(base_id)
if base_error:
if base_error.get("status") == "not_configured":
return {"required": False, "allowed": True, "status": "support_not_configured_legacy", "layer_id": layer_id, "source": "legacy_configuration"}
return {"required": True, "allowed": False, "status": "blocked_support_configuration", "layer_id": layer_id, "problem": base_error}
layers = base_item.get("development_layers") if isinstance(base_item.get("development_layers"), dict) else None
if layers is None:
return {"required": False, "allowed": True, "status": "support_not_configured_legacy", "layer_id": layer_id, "source": "legacy_configuration"}
layer = layers.get(layer_id)
if not isinstance(layer, dict):
return {"required": True, "allowed": False, "status": "blocked_support_layer_unknown", "layer_id": layer_id}
support = layer.get("support")
if not isinstance(support, dict):
return {"required": True, "allowed": False, "status": "blocked_support_unknown", "layer_id": layer_id, "source": "development_layer_configuration"}
mode = str(support.get("mode") or "unknown").strip().casefold()
if mode not in SUPPORTED_SUPPORT_MODES:
return {"required": True, "allowed": False, "status": "blocked_support_configuration", "layer_id": layer_id, "problem": {"message": "support.mode must be none, editable, locked, rules, or unknown."}}
if mode == "none":
return {"required": False, "allowed": True, "status": "not_on_support", "layer_id": layer_id, "mode": mode}
if mode == "editable":
return {"required": True, "allowed": True, "status": "support_editable", "layer_id": layer_id, "mode": mode}
if mode == "locked":
return {"required": True, "allowed": False, "status": "blocked_by_support", "layer_id": layer_id, "mode": mode}
target = payload.get("target") if isinstance(payload.get("target"), dict) else {}
scope = str(target.get("support_scope") or target.get("canonical_path") or target.get("path") or payload.get("support_scope") or payload.get("canonical_path") or payload.get("path") or payload.get("ref") or payload.get("object") or "").strip()
if mode == "rules" and scope:
rules = support.get("rules") if isinstance(support.get("rules"), dict) else {}
candidates = []
folded = scope.casefold()
for rule_scope, rule_value in rules.items():
key = str(rule_scope).strip()
if key and (folded == key.casefold() or folded.startswith(key.casefold() + ".")):
candidates.append((len(key), key, str(rule_value or "unknown").strip().casefold()))
if candidates:
_, matched_scope, rule = max(candidates)
allowed = rule in {"editable", "vendor_editable_support_preserved", "not_supported"}
return {
"required": True, "allowed": allowed,
"status": "support_rule_editable" if allowed else ("blocked_by_support_rule" if rule in {"locked", "vendor_not_editable"} else "blocked_support_unknown"),
"layer_id": layer_id, "mode": mode, "scope": scope, "matched_scope": matched_scope, "rule": rule,
}
return {"required": True, "allowed": False, "status": "blocked_support_unknown", "layer_id": layer_id, "mode": mode, "scope": scope or None}
def commit_plan(payload: dict[str, Any]) -> dict[str, Any]:
session_id = str(payload.get("lock_session_id") or "").strip()
session = (_read_state().get("sessions") or {}).get(session_id)
@@ -679,7 +945,7 @@ def commit(payload: dict[str, Any]) -> dict[str, Any]:
state = _read_state()
session = (state.get("sessions") or {}).get(session_id)
base_id = str(session.get("base_id") or "")
config, error = repository_config(base_id)
config, error = repository_config(base_id, str(session.get("layer_id") or session.get("layer") or "base"))
if error:
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "lock_session_id": session_id, **error}
with _base_lock(base_id, str(session.get("layer") or "base")):
@@ -704,7 +970,7 @@ def unlock(payload: dict[str, Any]) -> dict[str, Any]:
if not payload.get("allow_repository_unlock") is True:
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "explicit_repository_unlock_required", "lock_session_id": session_id}
base_id = str(session.get("base_id") or "")
config, error = repository_config(base_id)
config, error = repository_config(base_id, str(session.get("layer_id") or session.get("layer") or "base"))
if error:
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "lock_session_id": session_id, **error}
with _base_lock(base_id, str(session.get("layer") or "base")):
+102 -23
View File
@@ -104,6 +104,18 @@ OBJECT_SELECTOR_SCHEMA_PROPERTIES = {
}
CACHE_POLICIES = {"none", "ttl", "snapshot", "stale_while_revalidate"}
SOURCE_STATES = {"applied", "working", "all"}
CONFIGURATION_VIEW_TO_SOURCE_STATE = {
"effective": "working",
"effective_working": "working",
"designer": "working",
"working": "working",
"runtime": "applied",
"runtime_applied": "applied",
"applied": "applied",
"compare": "all",
"comparison": "all",
"both": "all",
}
REST_STATE_BY_SOURCE_STATE = {
"applied": "active",
"working": "working",
@@ -176,14 +188,24 @@ TOOLS = [
"payload.base_id is required; get it from user/project context or check a concrete base with onec_health first. "
"If you already have module_ref/read_selector, prefer direct read methods before global search. "
"Search results include read_selector.method; reuse that selector directly for the next read call. "
"For a name search across metadata objects, forms, attributes, commands, templates, routines, and extension definitions, use metadata.definition.find; "
"use code.search only when the query is BSL text. Scope extension objects with extension.objects.find or metadata.definition.find areas=extensions, never by SQL table names. "
"For unresolved module owners, inspect diagnostics.owner_resolution and adjust the object selector "
"(ref, kind/name/guid, or object_type/object_name/object_guid) or owner_scan_limit. "
"For programming in Configurator/designer, use source_state=working to read saved-but-not-applied metadata; "
"use source_state=applied for active metadata or source_state=all to compare both. "
"The default agent view is configuration_view=effective_working with source_state=working: the logical Designer snapshot, with working changes and extension layers preferred; "
"it becomes executable after configuration update, not necessarily now. Use configuration_view=runtime_applied for code executable now, or compare to inspect both. "
"Do not select Config/ConfigSave tables in ordinary programming calls. "
"For saved-state methods, select base_saved_state or extension_saved_state with layer and identify objects by ref or kind/name; "
"use table/file_name/module_ref only when continuing an explicit include_storage diagnostic result. "
"Before writes, call metadata.write.preflight when you need a read-only route/freshness check; it reports "
"ready, needs_prepare, needs_resolution, or blocked and never applies SQL writes. "
"Repository manual-capture protocol: when repository.lock.request or repository.lock.request.status returns "
"status=pending_user_lock, do not invent confirmation fields. After the user confirms the exact object is "
"captured in Configurator, call the returned next_call.method using next_call.params as this tool's payload. "
"The successful confirmation returns write_context; forward it unchanged as payload.repository_lock (or copy its fields to the payload top level) for write preflight and write calls. "
"For BSL edits, prefer high-level metadata.write: pass a 1C canonical path, routine_text, and routine_operation; "
"the adapter prepares saved-state when needed, saves into working/saved-state metadata, and never activates it. "
"For a scheduled-job schedule, use metadata.write with target.kind=schedule and target.ref such as РегламентныеЗадания.ОбменДанными; pass named schedule fields instead of GUIDs or tree paths. "
"For adding a form command with a visible button and handler routine, use metadata.form.command_button.write. "
"Use code.write only as a compatibility shortcut for simple module edits. "
"Long metadata calls return a job_id quickly; poll it with method mcp.job.get."
@@ -362,6 +384,16 @@ TOOLS = [
"routine_text": "<full-procedure-or-function-text>",
},
},
{
"method": "metadata.write",
"payload": {
"base_id": "<base_id-from-project-context>",
"target": {"kind": "schedule", "ref": "РегламентныеЗадания.<scheduled-job-name>"},
"schedule": {"begin_time": "09:00:00", "week_days": [1, 2, 3, 4, 5]},
"allow_saved_state_write": True,
"mode": "plan",
},
},
{
"method": "metadata.form.command_button.write",
"payload": {
@@ -417,9 +449,9 @@ TOOLS = [
"method": "metadata.saved_state.prepare",
"payload": {
"base_id": "<base_id-from-project-context>",
"target_table": "ConfigCASSave",
"object_type": "<metadata-kind>",
"object_name": "<metadata-object-name>",
"layer": "extension_saved_state",
"ref": "<metadata-kind>.<metadata-object-name>",
"extension": "<extension-name>",
"mode": "plan",
},
},
@@ -427,8 +459,9 @@ TOOLS = [
"method": "metadata.saved_state.diff",
"payload": {
"base_id": "<base_id-from-project-context>",
"table": "ConfigCASSave",
"file_name": "<saved-state-file-name>",
"ref": "<metadata-kind>.<metadata-object-name>",
"module_ordinal": 1,
"extension": "<extension-name-if-needed>",
"max_text_diff_lines": 80,
},
},
@@ -436,7 +469,7 @@ TOOLS = [
"method": "metadata.saved_state.status",
"payload": {
"base_id": "<base_id-from-project-context>",
"table": "ConfigCASSave",
"layer": "extension_saved_state",
"limit": 200,
},
},
@@ -444,6 +477,7 @@ TOOLS = [
"method": "metadata.saved_state.changes.list",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "extension_saved_state",
"limit": 200,
"include_context": True,
"group_by_context": True,
@@ -990,15 +1024,17 @@ TOOLS = [
},
{
"name": "access_object_explain",
"description": "Explain who can see a BSP-protected object/record by resolving object access keys to groups, user sets, and users.",
"description": "Explain who can see a BSP-protected data record. Prefer public object_ref plus record_ref; legacy raw BSP fields remain available.",
"inputSchema": {
"type": "object",
"required": ["base_id"],
"properties": {
"base_id": {"type": "string", "description": "Concrete 1C base id."},
"object": {"type": "string", "description": "Normalized object ref from access_object_keys.object."},
"object_id": {"type": "string", "description": "Data record id from access_object_keys.object_id."},
"object_sql_number": {"type": "integer"},
"object_ref": {"type": "string", "description": "Public metadata ref, for example Справочники.Номенклатура."},
"record_ref": {"type": "string", "description": "Concrete 1C application-data record reference."},
"object": {"type": "string", "description": "Legacy raw BSP object value."},
"object_id": {"type": "string", "description": "Legacy raw BSP object_id; prefer record_ref."},
"object_sql_number": {"type": "integer", "description": "Legacy physical selector; prefer object_ref."},
"access_key": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000},
"subject_limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000},
@@ -1010,20 +1046,25 @@ TOOLS = [
},
{
"name": "access_keys_query",
"description": "Page through BSP access key registers by group, user set, object, access set, or all. Use for data restriction diagnostics, not for metadata-object role rights.",
"description": "Page through BSP access key registers. kind selects the query area, not a metadata kind; in object mode prefer object_ref plus optional record_ref.",
"inputSchema": {
"type": "object",
"required": ["base_id"],
"properties": {
"base_id": {"type": "string", "description": "Concrete 1C base id."},
"kind": {"type": "string", "description": "group, user, object, access_set, or all."},
"kind": {"type": "string", "description": "Query area: group, user_set, object, set, or all. This is not a 1C metadata kind."},
"area": {"type": "string", "description": "Alias of kind."},
"group": {"type": "string"},
"user": {"type": "string"},
"user_set": {"type": "string"},
"object": {"type": "string"},
"object_id": {"type": "string"},
"object_sql_number": {"type": "integer"},
"object_ref": {"type": "string", "description": "Public metadata ref, for example Справочники.Номенклатура."},
"record_ref": {"type": "string", "description": "Concrete 1C application-data record reference."},
"object": {"type": "string", "description": "Legacy raw BSP object value."},
"object_id": {"type": "string", "description": "Legacy raw BSP object_id; prefer record_ref."},
"object_sql_number": {"type": "integer", "description": "Legacy physical selector; prefer object_ref."},
"access_key": {"type": "string"},
"resolve_records": {"type": "boolean", "default": False},
"max_resolved_records": {"type": "integer", "minimum": 0, "maximum": 5000, "default": 200},
"limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000},
"offset": {"type": "integer", "minimum": 0, "default": 0},
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120},
@@ -1033,15 +1074,17 @@ TOOLS = [
},
{
"name": "access_object_keys_resolve",
"description": "Return BSP object access-key rows and resolve object records when possible. Accepts object names/public refs and internal ids.",
"description": "Return BSP object access-key rows and resolve record presentations. Prefer public object_ref plus optional record_ref.",
"inputSchema": {
"type": "object",
"required": ["base_id"],
"properties": {
"base_id": {"type": "string", "description": "Concrete 1C base id."},
"object": {"type": "string"},
"object_id": {"type": "string"},
"object_sql_number": {"type": "integer"},
"object_ref": {"type": "string", "description": "Public metadata ref, for example Справочники.Номенклатура."},
"record_ref": {"type": "string", "description": "Concrete 1C application-data record reference."},
"object": {"type": "string", "description": "Legacy raw BSP object value."},
"object_id": {"type": "string", "description": "Legacy raw BSP object_id; prefer record_ref."},
"object_sql_number": {"type": "integer", "description": "Legacy physical selector; prefer object_ref."},
"access_key": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000},
"offset": {"type": "integer", "minimum": 0, "default": 0},
@@ -1211,7 +1254,22 @@ def normalize_source_state(value: Any, source_mode: str) -> str:
return "working"
if value_text in SOURCE_STATES:
return value_text
return "working" if source_mode == "designer" else "applied"
# Agents program against the logical Designer configuration by default.
# Runtime remains explicit through source_mode=runtime or source_state=applied.
return "applied" if source_mode == "runtime" else "working"
def normalize_configuration_view(value: Any, source_state: str) -> tuple[str, str]:
view = str(value or "").strip().casefold()
if not view:
return ("effective_working" if source_state == "working" else "runtime_applied" if source_state == "applied" else "compare", source_state)
mapped = CONFIGURATION_VIEW_TO_SOURCE_STATE.get(view)
if not mapped:
return "", ""
if source_state and source_state != mapped:
return "", ""
canonical = "effective_working" if mapped == "working" else "runtime_applied" if mapped == "applied" else "compare"
return canonical, mapped
def normalize_cache_policy(value: Any, source_mode: str) -> str:
@@ -2213,7 +2271,15 @@ def apply_freshness_request_policy(payload: dict[str, Any], method: str) -> dict
if method == "code.write":
source_mode = "designer"
cache_policy = normalize_cache_policy(payload.get("cache_policy"), source_mode)
source_state = normalize_source_state(payload.get("source_state"), source_mode)
explicit_source_state = normalize_source_state(payload.get("source_state"), source_mode) if payload.get("source_state") is not None else ""
configuration_view, source_state = normalize_configuration_view(payload.get("configuration_view"), explicit_source_state)
if not configuration_view:
transformed = dict(payload)
transformed["_configuration_view_error"] = "configuration_view conflicts with source_state or is unsupported."
return transformed
if not source_state:
source_state = normalize_source_state(None, source_mode)
configuration_view, source_state = normalize_configuration_view(payload.get("configuration_view"), source_state)
if method == "code.write":
source_state = "working"
cache_policy = "none"
@@ -2221,6 +2287,7 @@ def apply_freshness_request_policy(payload: dict[str, Any], method: str) -> dict
transformed = dict(payload)
transformed["source_mode"] = source_mode
transformed["source_state"] = source_state
transformed["configuration_view"] = configuration_view
transformed["cache_policy"] = cache_policy
if method in REST_STATE_METHODS and "state" not in transformed:
transformed["state"] = REST_STATE_BY_SOURCE_STATE.get(source_state, "working")
@@ -2878,7 +2945,19 @@ def metadata_write_code_guardrail(method: str, payload: dict[str, Any]) -> dict[
def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any:
request_start = now_ts()
request_id = uuid.uuid4().hex
if method == "code.search" and payload.get("extension_guid") in {None, ""}:
payload = dict(payload)
payload.pop("extension_guid", None)
request_payload = apply_freshness_request_policy(payload, method)
configuration_view_error = request_payload.pop("_configuration_view_error", None)
if configuration_view_error:
return {
"schema": "adapter_1c_mcp_policy.v1",
"status": "invalid_argument",
"method": method,
"error": "invalid_configuration_view",
"diagnostics": {"message": str(configuration_view_error)},
}
request_payload["_mcp_request_id"] = request_id
payload = request_payload
code_guardrail = metadata_write_code_guardrail(method, payload)
+8
View File
@@ -20,6 +20,10 @@ infobase.
- `xml_metadata.py`: small XML metadata extractor used as validation oracle.
- `structured_metadata.py`: evidence-based projection from Config payloads to
normalized metadata records.
- `common_command.py`: adapter-independent reverse index from
`CommonCommand.Group` to command-group membership.
- `scheduled_job.py`: adapter-independent scheduled-job schedule decoder,
named-field validator, and verified tree rebuilder.
## Current Guarantees
@@ -42,6 +46,10 @@ The parser can currently:
evidence paths.
- attach child metadata items to concrete section record paths when a declared
child-record container is present.
- resolve command-group membership from CommonCommand payloads without
requiring callers to know GUIDs or storage paths.
- decode scheduled-job schedule payloads and build guarded named-field edits,
including weekday/month collection resize without exposing tree paths.
## Non-Goals At This Layer
+37 -1
View File
@@ -9,7 +9,13 @@ from .payload import (
payload_to_text,
try_decompress,
)
from .dbnames import DBNamesRecord, parse_dbnames_bytes, parse_dbnames_file
from .dbnames import (
DBNamesRecord,
parse_dbnames_bytes,
parse_dbnames_file,
parse_dbnames_version_bytes,
parse_dbnames_version_file,
)
from .extensions import (
ExtensionZippedInfo,
ManifestEntry,
@@ -20,6 +26,23 @@ from .config_object import MetadataObjectIdentity, find_identity, parse_config_o
from .storage import StorageRoute, group_records_by_guid, storage_route, storage_routes
from .config_sections import SectionSummary, summarize_section, summarize_sections
from .xml_metadata import XmlMetadataItem, extract_xml_metadata_items, group_xml_items
from .support_rules import (
SupplierSupport,
SupportRule,
parse_parent_configurations_bytes,
parse_parent_configurations_file,
)
from .common_command import (
common_command_group_guid,
index_common_command_groups,
parse_common_command_tree,
)
from .scheduled_job import (
decode_schedule,
rebuild_schedule_tree,
schedule_layout,
schedule_write_edits,
)
__all__ = [
"BraceNode",
@@ -32,6 +55,8 @@ __all__ = [
"DBNamesRecord",
"parse_dbnames_bytes",
"parse_dbnames_file",
"parse_dbnames_version_bytes",
"parse_dbnames_version_file",
"ExtensionZippedInfo",
"ManifestEntry",
"parse_extension_manifest_bytes",
@@ -49,4 +74,15 @@ __all__ = [
"XmlMetadataItem",
"extract_xml_metadata_items",
"group_xml_items",
"SupplierSupport",
"SupportRule",
"parse_parent_configurations_bytes",
"parse_parent_configurations_file",
"common_command_group_guid",
"index_common_command_groups",
"parse_common_command_tree",
"decode_schedule",
"rebuild_schedule_tree",
"schedule_layout",
"schedule_write_edits",
]
+89
View File
@@ -0,0 +1,89 @@
"""Pure reverse index for 1C common-command group membership."""
from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from typing import Any
from .payload import decode_payload_lossless, parse_brace_text
_GUID_RE = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
def _tree_item_at_path(tree: Any, path: tuple[int, ...]) -> Any | None:
node = tree
for index in path:
if not isinstance(node, dict) or not isinstance(node.get("items"), list):
return None
items = node["items"]
if index < 0 or index >= len(items):
return None
node = items[index]
return node
def _tree_scalar_at_path(tree: Any, path: tuple[int, ...]) -> str:
node = _tree_item_at_path(tree, path)
if isinstance(node, dict) and node.get("type") in {"atom", "string"}:
return str(node.get("value") or "")
return ""
def common_command_group_guid(tree: Any) -> str | None:
"""Return the group GUID stored in an observed CommonCommand Config tree."""
body = _tree_item_at_path(tree, (1, 1, 2))
group_guid = _tree_scalar_at_path(body, (7, 1)).strip().lower()
return group_guid if _GUID_RE.fullmatch(group_guid) else None
def parse_common_command_tree(data: bytes) -> Any | None:
"""Decode a CommonCommand payload without raising on unsupported data."""
try:
decoded = decode_payload_lossless(data)
text = decoded.get("text")
if not text or "{" not in text:
return None
return parse_brace_text(text)
except Exception:
return None
def index_common_command_groups(
commands: Iterable[Mapping[str, Any]],
payloads: Mapping[str, bytes],
) -> dict[str, Any]:
"""Build ``CommandGroup GUID -> CommonCommand rows`` from Config payloads."""
command_rows = [dict(item) for item in commands]
normalized_payloads = {str(key).strip().lower(): value for key, value in payloads.items()}
groups: dict[str, list[dict[str, Any]]] = {}
source_missing = 0
undecodable = 0
unassigned = 0
for item in command_rows:
guid = str(item.get("guid") or "").strip().lower()
data = normalized_payloads.get(guid)
if not data:
source_missing += 1
continue
tree = parse_common_command_tree(data)
if tree is None:
undecodable += 1
continue
group_guid = common_command_group_guid(tree)
if group_guid is None:
unassigned += 1
continue
groups.setdefault(group_guid, []).append(item)
return {
"groups": groups,
"scanned": len(command_rows),
"source_missing": source_missing,
"undecodable": undecodable,
"unassigned": unassigned,
}
+42
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
import re
from typing import Any
from .payload import parse_brace_text, payload_to_text, scalar
@@ -77,3 +78,44 @@ def parse_dbnames_bytes(data: bytes, *, source: str = "DBNames") -> dict[str, An
def parse_dbnames_file(path: Path) -> dict[str, Any]:
return parse_dbnames_bytes(path.read_bytes(), source=path.name)
_GUID_RE = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
def parse_dbnames_version_bytes(data: bytes, *, source: str = "DBNamesVersion") -> dict[str, Any]:
"""Parse the version marker stored separately from DBNames records."""
decoded = payload_to_text(data)
text = decoded.get("text")
if text is None:
raise ValueError(f"{source}: cannot decode DBNamesVersion text")
parsed = _unwrap_bom_sequence(parse_brace_text(text))
if not (isinstance(parsed, dict) and parsed.get("type") == "list"):
raise ValueError(f"{source}: expected root list")
items = parsed.get("items") or []
if len(items) != 2:
raise ValueError(f"{source}: expected 2 root items, got {len(items)}")
if not all(isinstance(item, dict) and item.get("type") == "atom" for item in items):
actual_types = [item.get("type") if isinstance(item, dict) else type(item).__name__ for item in items]
raise ValueError(f"{source}: expected scalar marker and version, got {actual_types}")
marker_text = scalar(items[0]).strip()
try:
marker = int(marker_text)
except (TypeError, ValueError) as exc:
raise ValueError(f"{source}: marker must be an integer, got {marker_text!r}") from exc
version = scalar(items[1]).strip().lower()
if not _GUID_RE.fullmatch(version):
raise ValueError(f"{source}: version must be a GUID string, got {version!r}")
return {
"source": source,
"compression": decoded["compression"],
"encoding": decoded["encoding"],
"marker": marker,
"version": version,
}
def parse_dbnames_version_file(path: Path) -> dict[str, Any]:
return parse_dbnames_version_bytes(path.read_bytes(), source=path.name)
+451
View File
@@ -0,0 +1,451 @@
"""Pure decoder and safe tree editor for 1C scheduled-job schedules."""
from __future__ import annotations
import copy
import re
from datetime import datetime
from typing import Any
WRITABLE_SCALAR_FIELDS = {
"begin_date",
"end_date",
"begin_time",
"end_time",
"completion_time",
"completion_interval",
"repeat_period_in_day",
"repeat_pause",
"week_day_in_month",
"day_in_month",
"weeks_period",
"days_repeat_period",
}
WRITABLE_LIST_FIELDS = {"week_days", "months"}
INTEGER_RANGES = {
"completion_interval": (0, 2_147_483_647),
"repeat_period_in_day": (0, 2_147_483_647),
"repeat_pause": (0, 2_147_483_647),
"week_day_in_month": (0, 5),
"day_in_month": (0, 31),
"weeks_period": (0, 2_147_483_647),
"days_repeat_period": (0, 2_147_483_647),
}
def _scalar(node: Any) -> str:
if not isinstance(node, dict):
return str(node or "")
if node.get("type") in {"atom", "string"}:
return str(node.get("value") or "")
return ""
def schedule_datetime(raw: str) -> tuple[str | None, str | None]:
if not re.fullmatch(r"\d{14}", raw):
return None, None
return (
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]}",
f"{raw[8:10]}:{raw[10:12]}:{raw[12:14]}",
)
def schedule_layout(tree: Any) -> dict[str, Any]:
items = tree.get("items") if isinstance(tree, dict) and isinstance(tree.get("items"), list) else []
raw = [_scalar(item) for item in items]
if len(raw) < 13:
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "The SQL schedule payload is shorter than the supported format."},
}
week_day_count = int(raw[8]) if re.fullmatch(r"-?\d+", raw[8]) else None
cursor = 9
if week_day_count is None or week_day_count < 0 or cursor + week_day_count > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "Invalid weekday collection in the SQL schedule payload."},
}
week_days_start = cursor
cursor += week_day_count
if cursor + 3 > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "The SQL schedule payload has no month collection header."},
}
week_day_in_month_index = cursor
day_in_month_index = cursor + 1
month_count_index = cursor + 2
month_count = int(raw[month_count_index]) if re.fullmatch(r"-?\d+", raw[month_count_index]) else None
cursor += 3
if month_count is None or month_count < 0 or cursor + month_count > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "Invalid month collection in the SQL schedule payload."},
}
months_start = cursor
cursor += month_count
if cursor + 2 > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "The SQL schedule payload has no repeat-period tail."},
}
return {
"status": "ok",
"raw": raw,
"indexes": {
"begin_date": 0,
"end_date": 1,
"begin_time": 2,
"end_time": 3,
"completion_time": 4,
"completion_interval": 5,
"repeat_period_in_day": 6,
"repeat_pause": 7,
"week_day_count": 8,
"week_days": list(range(week_days_start, week_days_start + week_day_count)),
"week_day_in_month": week_day_in_month_index,
"day_in_month": day_in_month_index,
"month_count": month_count_index,
"months": list(range(months_start, months_start + month_count)),
"weeks_period": cursor,
"days_repeat_period": cursor + 1,
},
"cursor": cursor + 2,
}
def decode_schedule(tree: Any, *, include_storage: bool = False) -> dict[str, Any]:
layout = schedule_layout(tree)
if layout.get("status") != "ok":
return layout
raw = layout["raw"]
indexes = layout["indexes"]
def integer(field: str) -> int | None:
value = raw[indexes[field]]
return int(value) if re.fullmatch(r"-?\d+", value) else None
begin_date, _ = schedule_datetime(raw[indexes["begin_date"]])
end_date, _ = schedule_datetime(raw[indexes["end_date"]])
_, begin_time = schedule_datetime(raw[indexes["begin_time"]])
_, end_time = schedule_datetime(raw[indexes["end_time"]])
_, completion_time = schedule_datetime(raw[indexes["completion_time"]])
result = {
"status": "ok",
"begin_date": begin_date,
"end_date": end_date,
"begin_time": begin_time,
"end_time": end_time,
"completion_time": completion_time,
"completion_interval": integer("completion_interval"),
"repeat_period_in_day": integer("repeat_period_in_day"),
"repeat_pause": integer("repeat_pause"),
"week_days": [int(raw[index]) for index in indexes["week_days"] if raw[index].isdigit()],
"week_day_in_month": integer("week_day_in_month"),
"day_in_month": integer("day_in_month"),
"months": [int(raw[index]) for index in indexes["months"] if raw[index].isdigit()],
"weeks_period": integer("weeks_period"),
"days_repeat_period": integer("days_repeat_period"),
"evidence": "live_sql_config_schedule_decoder",
}
if include_storage:
result["storage"] = {
"format": "scheduled_job_config_suffix_0",
"raw_values": raw,
"trailing_values": raw[int(layout["cursor"]) :],
}
return result
def rebuild_schedule_tree(tree: Any, requested: dict[str, Any]) -> dict[str, Any]:
layout = schedule_layout(tree)
if layout.get("status") != "ok":
return layout
raw = list(layout["raw"])
indexes = layout["indexes"]
current = decode_schedule(tree)
for field in WRITABLE_SCALAR_FIELDS:
if field not in requested:
continue
value = requested[field]
index = int(indexes[field])
if field in {"begin_date", "end_date"}:
raw[index] = str(value).replace("-", "") + (
raw[index][8:] if re.fullmatch(r"\d{14}", raw[index]) else "000000"
)
elif field in {"begin_time", "end_time", "completion_time"}:
raw[index] = (
raw[index][:8] if re.fullmatch(r"\d{14}", raw[index]) else "00010101"
) + str(value).replace(":", "")
else:
raw[index] = str(value)
week_days = list(requested.get("week_days", current.get("week_days") or []))
months = list(requested.get("months", current.get("months") or []))
rebuilt_raw = [
*raw[:8],
str(len(week_days)),
*(str(value) for value in week_days),
raw[int(indexes["week_day_in_month"])],
raw[int(indexes["day_in_month"])],
str(len(months)),
*(str(value) for value in months),
raw[int(indexes["weeks_period"])],
raw[int(indexes["days_repeat_period"])],
*raw[int(layout["cursor"]) :],
]
rebuilt_tree = copy.deepcopy(tree)
rebuilt_tree["items"] = [{"type": "atom", "value": value} for value in rebuilt_raw]
verification = decode_schedule(rebuilt_tree)
if verification.get("status") != "ok":
return {
"status": "error",
"error": "schedule_rebuild_verification_failed",
"diagnostics": {"message": "The rebuilt scheduled-job tree did not pass the schedule decoder."},
}
for field, expected in requested.items():
if verification.get(field) != expected:
return {
"status": "error",
"error": "schedule_rebuild_verification_failed",
"field": field,
"diagnostics": {
"message": "A named schedule field changed during tree rebuild verification.",
"expected": expected,
"actual": verification.get(field),
},
}
return {
"status": "ok",
"tree": rebuilt_tree,
"schedule": verification,
"old_counts": {
"week_days": len(indexes["week_days"]),
"months": len(indexes["months"]),
},
"new_counts": {
"week_days": len(week_days),
"months": len(months),
},
}
def schedule_write_edits(tree: Any, requested: Any) -> dict[str, Any]:
if not isinstance(requested, dict) or not requested:
return {
"status": "invalid_argument",
"error": "schedule_required",
"diagnostics": {"message": "schedule must be a non-empty JSON object."},
}
unknown = sorted(set(requested) - WRITABLE_SCALAR_FIELDS - WRITABLE_LIST_FIELDS)
if unknown:
return {
"status": "invalid_argument",
"error": "unsupported_schedule_fields",
"diagnostics": {
"message": "The request contains unsupported scheduled-job fields.",
"fields": unknown,
"allowed_fields": sorted(WRITABLE_SCALAR_FIELDS | WRITABLE_LIST_FIELDS),
},
}
layout = schedule_layout(tree)
if layout.get("status") != "ok":
return layout
current = decode_schedule(tree)
raw = layout["raw"]
indexes = layout["indexes"]
normalized: dict[str, Any] = {}
edits: list[dict[str, Any]] = []
resized_collections: list[str] = []
for field, value in requested.items():
if field in {"begin_date", "end_date"}:
if value is None:
compact = "00010101"
elif isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
try:
datetime.fromisoformat(value)
except ValueError:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be a real ISO date YYYY-MM-DD."},
}
compact = value.replace("-", "")
else:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be an ISO date YYYY-MM-DD or null."},
}
index = int(indexes[field])
encoded = compact + (raw[index][8:] if re.fullmatch(r"\d{14}", raw[index]) else "000000")
normalized[field] = "0001-01-01" if value is None else value
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
continue
if field in {"begin_time", "end_time", "completion_time"}:
if value is None:
compact = "000000"
elif isinstance(value, str) and re.fullmatch(r"\d{2}:\d{2}:\d{2}", value):
try:
datetime.strptime(value, "%H:%M:%S")
except ValueError:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be a real time HH:MM:SS."},
}
compact = value.replace(":", "")
else:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be a time HH:MM:SS or null."},
}
index = int(indexes[field])
encoded = (
raw[index][:8] if re.fullmatch(r"\d{14}", raw[index]) else "00010101"
) + compact
normalized[field] = "00:00:00" if value is None else value
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
continue
if field in WRITABLE_LIST_FIELDS:
maximum = 7 if field == "week_days" else 12
if (
not isinstance(value, list)
or any(
isinstance(item, bool)
or not isinstance(item, int)
or item < 1
or item > maximum
for item in value
)
or len(set(value)) != len(value)
):
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {
"message": f"{field} must be a JSON array of unique integers from 1 to {maximum}."
},
}
field_indexes = list(indexes[field])
if len(value) != len(field_indexes):
resized_collections.append(field)
normalized[field] = list(value)
continue
normalized[field] = list(value)
for index, item in zip(field_indexes, value):
encoded = str(item)
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
continue
minimum, maximum = INTEGER_RANGES[field]
if isinstance(value, bool) or not isinstance(value, int) or value < minimum or value > maximum:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {
"message": f"{field} must be a JSON integer from {minimum} to {maximum}."
},
}
index = int(indexes[field])
encoded = str(value)
normalized[field] = value
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
if resized_collections:
rebuilt = rebuild_schedule_tree(tree, normalized)
if rebuilt.get("status") != "ok":
return rebuilt
edits = [
{
"replace_root": rebuilt["tree"],
"fields": sorted(normalized),
"resized_collections": sorted(resized_collections),
"old_counts": rebuilt["old_counts"],
"new_counts": rebuilt["new_counts"],
}
]
return {
"status": "ok",
"current": current,
"requested": normalized,
"edits": edits,
"counts": {
"requested_fields": len(normalized),
"edits": len(edits),
"resized_collections": len(resized_collections),
},
}
# Compatibility aliases retained by adapter_1c_server and existing clients.
config_schedule_datetime = schedule_datetime
scheduled_job_schedule_layout = schedule_layout
scheduled_job_sql_schedule = decode_schedule
scheduled_job_schedule_rebuild_tree = rebuild_schedule_tree
scheduled_job_schedule_write_edits = schedule_write_edits
SCHEDULED_JOB_WRITABLE_SCALAR_FIELDS = WRITABLE_SCALAR_FIELDS
SCHEDULED_JOB_WRITABLE_LIST_FIELDS = WRITABLE_LIST_FIELDS
SCHEDULED_JOB_INTEGER_RANGES = INTEGER_RANGES
__all__ = [
"INTEGER_RANGES",
"WRITABLE_LIST_FIELDS",
"WRITABLE_SCALAR_FIELDS",
"config_schedule_datetime",
"decode_schedule",
"rebuild_schedule_tree",
"schedule_datetime",
"schedule_layout",
"schedule_write_edits",
"scheduled_job_schedule_layout",
"scheduled_job_schedule_rebuild_tree",
"scheduled_job_schedule_write_edits",
"scheduled_job_sql_schedule",
]
+140
View File
@@ -0,0 +1,140 @@
"""Decoder for exported 1C ``ParentConfigurations.bin`` support rules.
The SQL representation of these data is platform-private and may differ from
the exported representation. Callers must therefore pass bytes from a
positively identified source; this module deliberately does not discover a
source or infer that missing data means "not on support".
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
import re
from typing import Any
from .payload import parse_brace_text, payload_to_text, scalar
GUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
OBJECT_RULES = {
0: "not_editable",
1: "editable_support_preserved",
2: "not_supported",
}
@dataclass(frozen=True)
class SupportRule:
object_guid: str
rule_code: int
rule: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class SupplierSupport:
configuration_guid: str
general_mode_code: int
general_mode: str
version: str
producer: str
name: str
declared_object_count: int
rules: tuple[SupportRule, ...]
def to_dict(self) -> dict[str, Any]:
result = asdict(self)
result["rules"] = [rule.to_dict() for rule in self.rules]
return result
def _root_items(data: bytes, source: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
decoded = payload_to_text(data)
text = decoded.get("text")
if text is None:
raise ValueError(f"{source}: cannot decode support rules text")
root = parse_brace_text(text)
if not (isinstance(root, dict) and root.get("type") == "list"):
raise ValueError(f"{source}: expected root list")
return list(root.get("items") or []), decoded
def _integer(items: list[dict[str, Any]], index: int, source: str, field: str) -> int:
try:
return int(scalar(items[index]))
except (IndexError, TypeError, ValueError) as exc:
raise ValueError(f"{source}: invalid {field} at item {index}") from exc
def parse_parent_configurations_bytes(
data: bytes,
*,
source: str = "ParentConfigurations.bin",
) -> dict[str, Any]:
"""Decode a positively identified exported support-rules payload."""
items, decoded = _root_items(data, source)
if len(items) < 3:
raise ValueError(f"{source}: support rules header is incomplete")
format_marker = _integer(items, 0, source, "format marker")
if format_marker != 6:
raise ValueError(f"{source}: expected format marker 6, got {format_marker}")
supplier_count = _integer(items, 2, source, "supplier count")
if supplier_count < 0:
raise ValueError(f"{source}: supplier count must not be negative")
suppliers: list[SupplierSupport] = []
position = 3
for supplier_index in range(supplier_count):
if position + 6 >= len(items):
raise ValueError(f"{source}: supplier {supplier_index} header is incomplete")
configuration_guid = scalar(items[position]).lower()
if not GUID_RE.fullmatch(configuration_guid):
raise ValueError(f"{source}: supplier {supplier_index} configuration GUID is invalid")
general_code = _integer(items, position + 1, source, "general support mode")
object_count = _integer(items, position + 6, source, "object count")
if object_count < 0:
raise ValueError(f"{source}: supplier {supplier_index} object count must not be negative")
object_position = position + 7
rules: list[SupportRule] = []
for object_index in range(object_count):
current = object_position + object_index * 4
if current + 3 >= len(items):
raise ValueError(f"{source}: supplier {supplier_index} object {object_index} is incomplete")
rule_code = _integer(items, current, source, "object support rule")
object_guid = scalar(items[current + 2]).lower()
if rule_code not in OBJECT_RULES:
raise ValueError(f"{source}: unsupported object rule code {rule_code}")
if not GUID_RE.fullmatch(object_guid):
raise ValueError(f"{source}: supplier {supplier_index} object {object_index} GUID is invalid")
effective_code = 0 if general_code != 0 else rule_code
rules.append(SupportRule(object_guid, effective_code, OBJECT_RULES[effective_code]))
suppliers.append(
SupplierSupport(
configuration_guid=configuration_guid,
general_mode_code=general_code,
general_mode="editable" if general_code == 0 else "locked",
version=scalar(items[position + 3]),
producer=scalar(items[position + 4]),
name=scalar(items[position + 5]),
declared_object_count=object_count,
rules=tuple(rules),
)
)
position = object_position + object_count * 4 + 2
return {
"source": source,
"compression": decoded["compression"],
"encoding": decoded["encoding"],
"format_marker": format_marker,
"supplier_count": supplier_count,
"suppliers": suppliers,
}
def parse_parent_configurations_file(path: Path) -> dict[str, Any]:
return parse_parent_configurations_bytes(path.read_bytes(), source=path.name)
+30
View File
@@ -50,6 +50,8 @@ def run_checks() -> dict[str, Any]:
PARSER / "__init__.py",
PARSER / "payload.py",
PARSER / "cas_payload.py",
PARSER / "common_command.py",
PARSER / "scheduled_job.py",
]
missing = [str(path.relative_to(ROOT)) for path in required if not path.exists()]
require(not missing, f"missing standalone connector files: {missing}", failures)
@@ -96,7 +98,35 @@ def run_checks() -> dict[str, Any]:
require(openapi.get("openapi") == "3.1.0", "connector OpenAPI must parse as 3.1.0", failures)
paths = openapi.get("paths") if isinstance(openapi.get("paths"), dict) else {}
require("/health" in paths, "connector OpenAPI must expose /health", failures)
require("/methods" in paths, "connector OpenAPI must expose runtime /methods registry", failures)
require("/rpc" in paths, "connector OpenAPI must expose universal /rpc", failures)
require("/metadata/write-plan" in paths, "connector OpenAPI must expose /metadata/write-plan", failures)
schemas = ((openapi.get("components") or {}).get("schemas") or {}) if isinstance(openapi.get("components"), dict) else {}
require("AdapterRpcRequest" in schemas, "connector OpenAPI must define AdapterRpcRequest", failures)
require("AdapterMethodsResponse" in schemas, "connector OpenAPI must define AdapterMethodsResponse", failures)
try:
sys.path.insert(0, str(CONNECTOR))
sys.path.insert(0, str(CONNECTOR.parent))
import adapter_1c_server as adapter_server
runtime_paths = set(adapter_server.HTTP_GET_METHOD_ROUTES) | set(adapter_server.HTTP_POST_METHOD_ROUTES) | {"/rpc"}
require(
set(paths) == runtime_paths,
f"OpenAPI/runtime HTTP route drift: missing_in_openapi={sorted(runtime_paths - set(paths))}, missing_in_runtime={sorted(set(paths) - runtime_paths)}",
failures,
)
require(
all("get" in (paths.get(path) or {}) for path in adapter_server.HTTP_GET_METHOD_ROUTES),
"every runtime GET route must be declared as GET in OpenAPI",
failures,
)
require(
all("post" in (paths.get(path) or {}) for path in adapter_server.HTTP_POST_METHOD_ROUTES),
"every runtime POST route must be declared as POST in OpenAPI",
failures,
)
except Exception as exc:
failures.append(f"could not verify runtime HTTP route registry: {exc}")
return {
"schema": "onec_connector_standalone_check.v1",
@@ -92,13 +92,14 @@ def run_saved_state_search_with_object_name(params: dict[str, Any] | None = None
adapter_server.read_storage_file_bytes = lambda base_id, table, file_name, timeout_seconds=30: (stored, {"database": base_id}, None)
request_payload = {
"base_id": "upo_test",
"tables": ["ConfigCASSave"],
"layer": "extension_saved_state",
"object_type": "Catalog",
"object_name": "Номенклатура",
"query": "ПередЗаписью",
"limit": 10,
}
request_payload.update(params or {})
request_payload["include_storage"] = True
return adapter_server.call_method(
adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD,
request_payload,
@@ -126,7 +127,9 @@ def run_checks() -> dict[str, Any]:
require((unknown_evidence.get("target") or {}).get("extension_action") == unknown_action, "unknown action must be carried into write_plan_evidence target", failures)
require("intent" not in unknown_evidence, "unknown action must not infer write intent", failures)
require(unknown_next.get("method") == adapter_server.SAVED_STATE_MODULES_SEARCH_METHOD, "write_plan_evidence must expose saved-state module resolver", failures)
require(unknown_next_params.get("tables") == ["ConfigCASSave"], "extension write_plan_evidence must search ConfigCASSave", failures)
require(unknown_next_params.get("layer") == "extension_saved_state", "extension write_plan_evidence must use extension_saved_state", failures)
require(unknown_next_params.get("ref") == "Catalog.Номенклатура", "extension write_plan_evidence must use a public object ref", failures)
require("tables" not in unknown_next_params and "owner_guid" not in unknown_next_params, "public extension evidence must hide storage selectors", failures)
controlled = run_override({"operation_class": "replace_with_control"})
controlled_action = first_action(controlled)
@@ -152,7 +155,8 @@ def run_checks() -> dict[str, Any]:
base_next_params = base_next.get("params") if isinstance(base_next.get("params"), dict) else {}
require(base_action.get("operation_class") == "base_definition", "base routine must be marked as base_definition", failures)
require(base_action.get("requires_control_fragment") is False, "base routine must not require control fragment", failures)
require(base_next_params.get("tables") == ["ConfigSave"], "base write_plan_evidence must search ConfigSave", failures)
require(base_next_params.get("layer") == "base_saved_state", "base write_plan_evidence must use base_saved_state", failures)
require(base_next_params.get("ref") == "Catalog.Номенклатура", "base write_plan_evidence must use a public object ref", failures)
saved_state = run_saved_state_search_with_object_name(controlled_next_params)
saved_state_owner = saved_state.get("owner_resolution") if isinstance(saved_state.get("owner_resolution"), dict) else {}
+41
View File
@@ -87,6 +87,7 @@ def contract_checks(issues: list[dict[str, Any]], *, onec_request_present: bool)
for issue in issues
),
"mcp_tool_selector_guidance": not any(issue["code"] in {"mcp_tool_selector_guidance_missing", "mcp_examples_public_ref_placeholder_missing"} for issue in issues),
"mcp_saved_state_examples_name_first": not any(issue["code"].startswith("mcp_saved_state_example_") for issue in issues),
"all_adapter_methods_forward_over_rpc": not any(issue["code"].startswith("mcp_method") or issue["code"] == "mcp_rpc_body_method_mismatch" for issue in issues),
"no_unified_shadowing_adapter_methods": not any(issue["code"] == "mcp_unified_shadows_adapter_methods" for issue in issues),
"adapter_help_selector_guidance": not any(issue["code"] == "adapter_help_selector_guidance_missing" for issue in issues),
@@ -103,6 +104,7 @@ def contract_checks(issues: list[dict[str, Any]], *, onec_request_present: bool)
),
"adapter_definition_read_selector_public_ref": not any(issue["code"] == "adapter_definition_read_selector_public_ref_missing" for issue in issues),
"adapter_module_read_selector_public_ref": not any(issue["code"] == "adapter_module_read_selector_public_ref_missing" for issue in issues),
"agent_facing_object_methods_name_first": not any(issue["code"] == "agent_facing_object_method_not_name_first" for issue in issues),
"adapter_parse_ordinal_unpacked": not any(issue["code"] == "adapter_parse_ordinal_not_unpacked" for issue in issues),
"adapter_contract_version": not any(
issue["code"] in {"adapter_contract_version_missing", "mcp_contract_version_mismatch", "adapter_help_contract_version_missing"}
@@ -393,6 +395,23 @@ def check_contract() -> dict[str, Any]:
"actual": help_result.get("contract_version"),
}
)
agent_facing_object_terms = ("object", "module", "form", "template", "code")
for row in adapter_server.METHODS:
method = str(row.get("name") or "")
description = str(row.get("description") or "")
normalized_description = description.casefold()
if "agent-facing" not in normalized_description:
continue
if not any(term in normalized_description for term in agent_facing_object_terms):
continue
if method not in selector_alias_methods:
issues.append(
{
"code": "agent_facing_object_method_not_name_first",
"method": method,
"description": description,
}
)
for method in sorted(getattr(adapter_server, "OBJECT_SELECTOR_ALIAS_METHODS", set())):
capabilities = (public_methods.get(method) or {}).get("selector_capabilities")
if not isinstance(capabilities, dict) or not capabilities.get("accepts_ref") or not capabilities.get("accepts_object_aliases"):
@@ -448,6 +467,28 @@ def check_contract() -> dict[str, Any]:
examples_json = json.dumps(schema.get("examples") or [], ensure_ascii=False)
if "<metadata-kind>.<metadata-object-name>" not in examples_json:
issues.append({"code": "mcp_examples_public_ref_placeholder_missing"})
examples_by_method = {
str(example.get("method") or ""): example.get("payload")
for example in schema.get("examples") or []
if isinstance(example, dict) and isinstance(example.get("payload"), dict)
}
saved_state_example_contract = {
"metadata.saved_state.prepare": {"required": {"layer", "ref"}, "forbidden": {"target_table", "table", "file_name", "module_ref"}},
"metadata.saved_state.diff": {"required": {"ref", "module_ordinal"}, "forbidden": {"target_table", "table", "file_name", "module_ref"}},
"metadata.saved_state.status": {"required": {"layer"}, "forbidden": {"target_table", "table"}},
"metadata.saved_state.changes.list": {"required": {"layer"}, "forbidden": {"target_table", "table"}},
}
for method, requirements in saved_state_example_contract.items():
example_payload = examples_by_method.get(method)
if not isinstance(example_payload, dict):
issues.append({"code": "mcp_saved_state_example_missing", "method": method})
continue
missing = sorted(requirements["required"] - set(example_payload))
forbidden = sorted(requirements["forbidden"].intersection(example_payload))
if missing:
issues.append({"code": "mcp_saved_state_example_public_selector_missing", "method": method, "fields": missing})
if forbidden:
issues.append({"code": "mcp_saved_state_example_storage_selector_present", "method": method, "fields": forbidden})
if schema.get("required") != ["method"]:
issues.append({"code": "mcp_onec_request_required_not_generic", "required": schema.get("required")})
if "oneOf" in schema:
+484
View File
@@ -0,0 +1,484 @@
#!/usr/bin/env python3
"""Validate and optionally probe rare 1C metadata-kind fixtures."""
from __future__ import annotations
import argparse
import json
import os
import re
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
SCHEMA = "onec_metadata_kind_fixture_manifest.v1"
REPORT_SCHEMA = "onec_metadata_kind_fixture_check.v1"
SUPPORTED_MODES = {"extension_saved_state", "dedicated_base_active"}
REFERENCE_USAGE = "external_reference_only_not_vendored"
DESIGNER_ACCESS_MODE = "operating_system_integrated_only"
SECRET_KEY_RE = re.compile(r"(password|passwd|pwd|secret|token|парол|секрет)", re.IGNORECASE)
Rpc = Callable[[str, str, dict[str, Any], float, str], dict[str, Any]]
def load_json(path: Path) -> dict[str, Any]:
value = json.loads(path.read_text(encoding="utf-8-sig"))
if not isinstance(value, dict):
raise ValueError("manifest root must be a JSON object")
return value
def write_json(path: Path, value: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + ".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(path)
def rpc(adapter_url: str, method: str, payload: dict[str, Any], timeout: float, service_token: str) -> dict[str, Any]:
headers = {"Content-Type": "application/json; charset=utf-8"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
request = urllib.request.Request(
adapter_url.rstrip("/") + "/rpc",
data=json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8"),
headers=headers,
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
value = json.loads(response.read().decode("utf-8"))
return value if isinstance(value, dict) else {"status": "error", "error": "response_not_object"}
def finding(code: str, message: str, *, fixture_id: str | None = None) -> dict[str, Any]:
value = {"severity": "error", "code": code, "message": message}
if fixture_id:
value["fixture_id"] = fixture_id
return value
def secret_key_paths(value: Any, prefix: str = "") -> list[str]:
hits: list[str] = []
if isinstance(value, dict):
for key, nested in value.items():
path = f"{prefix}.{key}" if prefix else str(key)
if SECRET_KEY_RE.search(str(key)):
hits.append(path)
hits.extend(secret_key_paths(nested, path))
elif isinstance(value, list):
for index, nested in enumerate(value):
hits.extend(secret_key_paths(nested, f"{prefix}[{index}]"))
return hits
def validate_manifest(manifest: dict[str, Any]) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
if manifest.get("schema") != SCHEMA:
findings.append(finding("invalid_schema", f"schema must be {SCHEMA}"))
policy = manifest.get("policy")
if not isinstance(policy, dict):
findings.append(finding("missing_policy", "policy must be an object"))
else:
if policy.get("provisioning_tool") != "1c_designer":
findings.append(finding("unsafe_provisioning_tool", "fixtures must be provisioned by 1C Designer"))
if policy.get("direct_sql_write_allowed") is not False:
findings.append(finding("direct_sql_write_not_forbidden", "direct SQL fixture writes must be forbidden"))
if policy.get("credentials_in_manifest_allowed") is not False:
findings.append(finding("credentials_not_forbidden", "credentials must be forbidden in the manifest"))
for path in secret_key_paths(manifest):
findings.append(finding("credential_key_in_manifest", f"credential-like key is forbidden: {path}"))
designer = manifest.get("designer")
if not isinstance(designer, dict):
findings.append(finding("missing_designer_requirements", "designer requirements must be an object"))
designer = {}
required_version = str(designer.get("required_version") or "")
if not re.fullmatch(r"\d+\.\d+\.\d+\.\d+", required_version):
findings.append(finding("invalid_designer_version", "designer.required_version must be an exact four-part version"))
if designer.get("access_mode") != DESIGNER_ACCESS_MODE:
findings.append(finding("unsafe_designer_access_mode", f"designer.access_mode must be {DESIGNER_ACCESS_MODE}"))
if designer.get("credential_arguments_allowed") is not False:
findings.append(finding("designer_credential_arguments_not_forbidden", "Designer credential arguments must be forbidden"))
export_script = str(designer.get("export_script") or "")
if not export_script.endswith(".ps1") or Path(export_script).is_absolute():
findings.append(finding("invalid_designer_export_script", "designer.export_script must be a repository-relative PowerShell path"))
external_reference = manifest.get("external_reference")
if not isinstance(external_reference, dict):
findings.append(finding("missing_external_reference", "external_reference must be an object"))
external_reference = {}
if external_reference.get("usage") != REFERENCE_USAGE:
findings.append(finding("unsafe_external_reference_usage", f"external_reference.usage must be {REFERENCE_USAGE}"))
if not str(external_reference.get("repository") or "").startswith("https://"):
findings.append(finding("invalid_external_reference_repository", "external_reference.repository must be an HTTPS URL"))
if not re.fullmatch(r"[0-9a-f]{40}", str(external_reference.get("commit") or "")):
findings.append(finding("invalid_external_reference_commit", "external_reference.commit must be a full Git commit"))
for key in ("source_path", "license"):
if not str(external_reference.get(key) or "").strip():
findings.append(finding("incomplete_external_reference", f"external_reference.{key} is required"))
required_kinds = manifest.get("required_kinds")
if not isinstance(required_kinds, list) or not required_kinds or any(not isinstance(item, str) or not item for item in required_kinds):
findings.append(finding("invalid_required_kinds", "required_kinds must be a non-empty string array"))
required_kinds = []
fixtures = manifest.get("fixtures")
if not isinstance(fixtures, list):
findings.append(finding("invalid_fixtures", "fixtures must be an array"))
return findings
seen_ids: set[str] = set()
seen_kinds: set[str] = set()
for fixture in fixtures:
if not isinstance(fixture, dict):
findings.append(finding("invalid_fixture", "each fixture must be an object"))
continue
fixture_id = str(fixture.get("id") or "")
kind = str(fixture.get("kind") or "")
if not fixture_id:
findings.append(finding("missing_fixture_id", "fixture id is required"))
elif fixture_id in seen_ids:
findings.append(finding("duplicate_fixture_id", f"duplicate fixture id: {fixture_id}", fixture_id=fixture_id))
seen_ids.add(fixture_id)
if not kind:
findings.append(finding("missing_fixture_kind", "fixture kind is required", fixture_id=fixture_id))
elif kind in seen_kinds:
findings.append(finding("duplicate_fixture_kind", f"duplicate fixture kind: {kind}", fixture_id=fixture_id))
seen_kinds.add(kind)
target = fixture.get("target")
mode = target.get("mode") if isinstance(target, dict) else None
if mode not in SUPPORTED_MODES:
findings.append(finding("invalid_target_mode", f"unsupported target mode: {mode}", fixture_id=fixture_id))
if mode == "extension_saved_state":
for key in ("base_id", "extension", "state"):
if not isinstance(target.get(key), str) or not target.get(key):
findings.append(finding("missing_extension_target", f"target.{key} is required", fixture_id=fixture_id))
if required_version and target.get("platform_version") != required_version:
findings.append(
finding(
"fixture_designer_version_mismatch",
f"target.platform_version must equal designer.required_version ({required_version})",
fixture_id=fixture_id,
)
)
if mode == "dedicated_base_active" and target.get("base_override_required") is not True:
findings.append(finding("unsafe_shared_legacy_target", "dedicated legacy fixture must require an explicit base override", fixture_id=fixture_id))
selector = fixture.get("selector")
names = selector.get("names") if isinstance(selector, dict) else None
if not isinstance(names, list) or not names or any(not isinstance(name, str) or not name.strip() for name in names):
findings.append(finding("invalid_selector_names", "selector.names must be a non-empty string array", fixture_id=fixture_id))
methods = fixture.get("smoke_methods")
if not isinstance(methods, list) or not methods or any(not isinstance(method, str) or not method for method in methods):
findings.append(finding("invalid_smoke_methods", "smoke_methods must be a non-empty string array", fixture_id=fixture_id))
missing = sorted(set(required_kinds) - seen_kinds)
extra = sorted(seen_kinds - set(required_kinds))
if missing:
findings.append(finding("required_fixture_missing", f"fixtures missing for kinds: {', '.join(missing)}"))
if extra:
findings.append(finding("unexpected_fixture_kind", f"unexpected fixture kinds: {', '.join(extra)}"))
kind_paths = external_reference.get("kind_paths")
if not isinstance(kind_paths, dict):
findings.append(finding("missing_external_reference_kind_paths", "external_reference.kind_paths must be an object"))
else:
missing_reference_kinds = sorted(set(required_kinds) - set(kind_paths))
if missing_reference_kinds:
findings.append(
finding(
"external_reference_kind_missing",
f"external reference paths missing for kinds: {', '.join(missing_reference_kinds)}",
)
)
return findings
def normalize_guid(value: Any) -> str:
return str(value or "").strip().strip("{}").casefold()
def matches_selector(obj: dict[str, Any], selector: dict[str, Any]) -> bool:
names = {str(value).strip().casefold() for value in selector.get("names") or []}
guid = normalize_guid(selector.get("guid"))
object_name = str(obj.get("name") or "").strip().casefold()
object_guid = normalize_guid(obj.get("guid"))
if guid and object_guid == guid:
return True
return bool(object_name and object_name in names)
def object_public_selector(base_id: str, fixture: dict[str, Any], obj: dict[str, Any]) -> dict[str, Any]:
selector: dict[str, Any] = {"base_id": base_id, "kind": fixture["kind"]}
if obj.get("name"):
selector["name"] = obj["name"]
if obj.get("guid"):
selector["guid"] = obj["guid"]
target = fixture.get("target") or {}
route = obj.get("route") if isinstance(obj.get("route"), dict) else {}
extension_guid = obj.get("extension_guid") or route.get("extension_guid")
if extension_guid:
selector["extension_guid"] = extension_guid
elif target.get("extension"):
selector["extension"] = target["extension"]
return selector
def probe_smoke_methods(
adapter_url: str,
service_token: str,
timeout: float,
fixture: dict[str, Any],
base_id: str,
obj: dict[str, Any],
rpc_call: Rpc,
) -> list[dict[str, Any]]:
selector = object_public_selector(base_id, fixture, obj)
results: list[dict[str, Any]] = []
for method in fixture.get("smoke_methods") or []:
payload = {**selector, "timeout_seconds": max(1, int(timeout))}
if method == "metadata.object.attributes":
payload["only"] = "all"
try:
response = rpc_call(adapter_url, method, payload, timeout, service_token)
status = str(response.get("status") or "unknown")
results.append({"method": method, "status": status, **({"error": response.get("error")} if response.get("error") else {})})
except (TimeoutError, urllib.error.URLError, OSError, ValueError, json.JSONDecodeError) as exc:
results.append({"method": method, "status": "transport_error", "error": type(exc).__name__, "message": str(exc)[:300]})
return results
def check_fixture_live(
fixture: dict[str, Any],
adapter_url: str,
service_token: str,
timeout: float,
base_overrides: dict[str, str],
rpc_call: Rpc,
probe_cache: dict[tuple[str, str, str, str], dict[str, Any]] | None = None,
) -> dict[str, Any]:
fixture_id = str(fixture["id"])
target = fixture.get("target") or {}
base_id = base_overrides.get(fixture_id) or target.get("base_id")
if target.get("base_override_required") and fixture_id not in base_overrides:
return {
"id": fixture_id,
"kind": fixture["kind"],
"status": "target_not_configured",
"message": f"Pass --target-base {fixture_id}=BASE_ID for the dedicated legacy fixture.",
}
if not base_id:
return {"id": fixture_id, "kind": fixture["kind"], "status": "target_not_configured"}
payload: dict[str, Any]
method: str
if target.get("mode") == "extension_saved_state":
method = "extension.objects.find"
payload = {
"base_id": base_id,
"extension": target["extension"],
"state": target.get("state") or "working",
"limit": 100,
"use_cache": True,
"full_scan": False,
"refresh_cache": False,
"timeout_seconds": max(1, int(timeout)),
}
else:
method = "metadata.objects.list"
payload = {
"base_id": base_id,
"kind": fixture["kind"],
"limit": 100,
"refresh_cache": True,
"timeout_seconds": max(1, int(timeout)),
}
cache_key = (
method,
str(base_id),
str(target.get("extension") or ""),
str(target.get("state") or ""),
)
cached_probe_error: dict[str, Any] | None = None
try:
if probe_cache is not None and cache_key in probe_cache:
cached = probe_cache[cache_key]
cached_probe_error = cached.get("__probe_error__") if isinstance(cached.get("__probe_error__"), dict) else None
if cached_probe_error is not None:
raise TimeoutError(str(cached_probe_error.get("message") or "cached adapter probe failed"))
response = cached
else:
response = rpc_call(adapter_url, method, payload, timeout, service_token)
if probe_cache is not None:
probe_cache[cache_key] = response
except (TimeoutError, urllib.error.URLError, OSError, ValueError, json.JSONDecodeError) as exc:
error_name = str(cached_probe_error.get("error")) if cached_probe_error else type(exc).__name__
error_message = str(cached_probe_error.get("message")) if cached_probe_error else str(exc)[:300]
if probe_cache is not None and cached_probe_error is None:
probe_cache[cache_key] = {
"__probe_error__": {
"error": error_name,
"message": error_message,
}
}
return {
"id": fixture_id,
"kind": fixture["kind"],
"base_id": base_id,
"status": "adapter_error",
"probe_method": method,
"error": error_name,
"message": error_message,
**({"probe_error_reused": True} if cached_probe_error is not None else {}),
}
objects = response.get("objects") if isinstance(response.get("objects"), list) else response.get("items")
objects = [obj for obj in (objects or []) if isinstance(obj, dict)]
kind_objects = [obj for obj in objects if str(obj.get("kind") or "") == str(fixture["kind"])]
matched = next((obj for obj in kind_objects if matches_selector(obj, fixture.get("selector") or {})), None)
if not matched:
return {
"id": fixture_id,
"kind": fixture["kind"],
"base_id": base_id,
"status": "fixture_missing" if response.get("status") in {"ok", "not_found"} else "adapter_error",
"probe_method": method,
"adapter_status": response.get("status"),
"observed_names": sorted({str(obj.get("name")) for obj in kind_objects if obj.get("name")})[:20],
}
expected_states = set(fixture.get("expected_activation_states") or [])
activation_state = str(matched.get("activation_state") or "active")
smoke = probe_smoke_methods(adapter_url, service_token, timeout, fixture, base_id, matched, rpc_call)
failed_smoke = [item["method"] for item in smoke if item.get("status") != "ok"]
state_ok = not expected_states or activation_state in expected_states
return {
"id": fixture_id,
"kind": fixture["kind"],
"base_id": base_id,
"status": "fixture_ready" if state_ok and not failed_smoke else "fixture_degraded",
"probe_method": method,
"adapter_status": response.get("status"),
"object": {
"name": matched.get("name"),
"guid": matched.get("guid"),
"activation_state": activation_state,
},
"smoke": smoke,
**({"failed_smoke_methods": failed_smoke} if failed_smoke else {}),
**({"unexpected_activation_state": activation_state} if not state_ok else {}),
}
def parse_base_overrides(values: list[str]) -> dict[str, str]:
result: dict[str, str] = {}
for value in values:
fixture_id, separator, base_id = value.partition("=")
if not separator or not fixture_id.strip() or not base_id.strip():
raise ValueError(f"invalid --target-base value: {value}; expected FIXTURE_ID=BASE_ID")
result[fixture_id.strip()] = base_id.strip()
return result
def check_manifest(
manifest_path: Path,
*,
live: bool = False,
adapter_url: str = "http://docker-gpu.cin.su:8011",
service_token: str = "",
timeout: float = 90,
base_overrides: dict[str, str] | None = None,
require_ready: bool = False,
rpc_call: Rpc = rpc,
) -> dict[str, Any]:
try:
manifest = load_json(manifest_path)
except (OSError, ValueError, json.JSONDecodeError) as exc:
findings = [finding("manifest_read_failed", str(exc))]
manifest = {}
else:
findings = validate_manifest(manifest)
fixture_results: list[dict[str, Any]] = []
if not findings:
if live:
probe_cache: dict[tuple[str, str, str, str], dict[str, Any]] = {}
fixture_results = [
check_fixture_live(
fixture,
adapter_url,
service_token,
timeout,
base_overrides or {},
rpc_call,
probe_cache,
)
for fixture in manifest.get("fixtures") or []
]
else:
fixture_results = [
{"id": fixture["id"], "kind": fixture["kind"], "status": "not_checked"}
for fixture in manifest.get("fixtures") or []
]
ready = len([item for item in fixture_results if item.get("status") == "fixture_ready"])
gaps = len([item for item in fixture_results if item.get("status") in {"fixture_missing", "fixture_degraded", "target_not_configured", "adapter_error"}])
passed = not findings and (not require_ready or (bool(fixture_results) and ready == len(fixture_results)))
return {
"schema": REPORT_SCHEMA,
"created_at_utc": datetime.now(timezone.utc).isoformat(),
"status": "invalid_manifest" if findings else ("ready" if fixture_results and ready == len(fixture_results) else ("gaps_found" if live else "manifest_valid")),
"passed": passed,
"manifest_path": str(manifest_path),
"live": live,
"require_ready": require_ready,
"adapter_url": adapter_url if live else None,
"findings": findings,
"fixtures": fixture_results,
"counts": {
"findings": len(findings),
"fixtures": len(fixture_results),
"ready": ready,
"gaps": gaps,
},
}
def main() -> int:
parser = argparse.ArgumentParser(description="Validate and optionally probe rare 1C metadata-kind fixtures.")
parser.add_argument("--manifest", type=Path, default=Path("config/1c_metadata_kind_fixtures.json"))
parser.add_argument("--live", action="store_true")
parser.add_argument("--adapter-url", default="http://docker-gpu.cin.su:8011")
parser.add_argument("--service-token-env", default="ONEC_ADAPTER_SERVICE_TOKEN")
parser.add_argument("--timeout", type=float, default=90)
parser.add_argument("--target-base", action="append", default=[], metavar="FIXTURE_ID=BASE_ID")
parser.add_argument("--require-ready", action="store_true")
parser.add_argument("--output", type=Path)
args = parser.parse_args()
try:
base_overrides = parse_base_overrides(args.target_base)
except ValueError as exc:
parser.error(str(exc))
result = check_manifest(
args.manifest,
live=args.live,
adapter_url=args.adapter_url,
service_token=os.environ.get(args.service_token_env, ""),
timeout=args.timeout,
base_overrides=base_overrides,
require_ready=args.require_ready,
)
if args.output:
write_json(args.output, result)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result["passed"] else 2
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -334,6 +334,8 @@ def check_adapter_verify_wiring(scripts: list[Path], executable: str) -> list[st
failures.append("deploy_1c_adapter_stack.ps1 must declare -SkipWriteRollbackSafetySmoke.")
if "[switch]$SkipSavedStateDiffSmoke" not in deploy_text:
failures.append("deploy_1c_adapter_stack.ps1 must declare -SkipSavedStateDiffSmoke.")
if "[switch]$SkipCodeWriteSavedStateSmoke" not in deploy_text:
failures.append("deploy_1c_adapter_stack.ps1 must declare -SkipCodeWriteSavedStateSmoke.")
if "[string]$SavedStateTable" not in deploy_text or '[ValidateSet("ConfigSave", "ConfigCASSave")]' not in deploy_text:
failures.append("deploy_1c_adapter_stack.ps1 must declare -SavedStateTable with ConfigSave/ConfigCASSave validation.")
if '"-RequireSelectorChainWritePlanComposition"' not in deploy_text:
@@ -346,6 +348,8 @@ def check_adapter_verify_wiring(scripts: list[Path], executable: str) -> list[st
failures.append("deploy_1c_adapter_stack.ps1 must forward -SkipWriteRollbackSafetySmoke to verify_1c_adapter_deployment.ps1.")
if '"-SkipSavedStateDiffSmoke"' not in deploy_text:
failures.append("deploy_1c_adapter_stack.ps1 must forward -SkipSavedStateDiffSmoke to verify_1c_adapter_deployment.ps1.")
if '"-SkipCodeWriteSavedStateSmoke"' not in deploy_text:
failures.append("deploy_1c_adapter_stack.ps1 must forward -SkipCodeWriteSavedStateSmoke to verify_1c_adapter_deployment.ps1.")
if "function Get-DuplicateValues" not in deploy_text or "Duplicate BaseId value(s)" not in deploy_text:
failures.append("deploy_1c_adapter_stack.ps1 must reject duplicate -BaseId values before invoking verification.")
if "function Normalize-BaseIds" not in deploy_text or '-split ","' not in deploy_text:
+4
View File
@@ -23,6 +23,7 @@ param(
[switch]$SkipWriteRollbackSafetySmoke,
[switch]$SkipSavedStateDiffSmoke,
[switch]$SkipSavedStateWriteSmoke,
[switch]$SkipCodeWriteSavedStateSmoke,
[switch]$RequireSavedStateWriteSmoke,
[switch]$RequireSelectorChainWritePlanComposition
)
@@ -216,6 +217,9 @@ try {
if ($SkipSavedStateWriteSmoke) {
$verifyCommand += "-SkipSavedStateWriteSmoke"
}
if ($SkipCodeWriteSavedStateSmoke) {
$verifyCommand += "-SkipCodeWriteSavedStateSmoke"
}
if ($RequireSavedStateWriteSmoke) {
$verifyCommand += "-RequireSavedStateWriteSmoke"
}
+75
View File
@@ -0,0 +1,75 @@
[CmdletBinding()]
param(
[string]$DesignerPath = "C:\Program Files\1cv8\8.5.1.1236\bin\1cv8.exe",
[string]$ExpectedVersion = "8.5.1.1236",
[string]$ServerConnection = "wsr\upo_test",
[string]$Extension = "test2",
[Parameter(Mandatory = $true)]
[string]$OutputDirectory
)
$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest
if (-not (Test-Path -LiteralPath $DesignerPath -PathType Leaf)) {
throw "1C Designer executable not found: $DesignerPath"
}
$actualVersion = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($DesignerPath).FileVersion
if ($actualVersion -ne $ExpectedVersion) {
throw "1C Designer version mismatch: expected $ExpectedVersion, found $actualVersion"
}
$resolvedOutput = [System.IO.Path]::GetFullPath($OutputDirectory)
if (Test-Path -LiteralPath $resolvedOutput) {
$existingItems = @(Get-ChildItem -LiteralPath $resolvedOutput -Force)
if ($existingItems.Count -gt 0) {
throw "Output directory must be empty: $resolvedOutput"
}
}
else {
New-Item -ItemType Directory -Path $resolvedOutput | Out-Null
}
$logPath = [System.IO.Path]::GetTempFileName()
try {
$designerArguments = @(
"DESIGNER",
"/S", $ServerConnection,
"/WA+",
"/DisableStartupMessages",
"/DisableStartupDialogs",
"/Out", $logPath,
"/DumpConfigToFiles", $resolvedOutput,
"-Extension", $Extension
)
& $DesignerPath @designerArguments
$designerExitCode = $LASTEXITCODE
$designerLog = Get-Content -LiteralPath $logPath -Raw -ErrorAction SilentlyContinue
if ($designerExitCode -ne 0) {
$details = if ($designerLog) { $designerLog.Trim() } else { "Designer did not write a diagnostic log." }
throw "1C Designer export failed with exit code ${designerExitCode}: $details"
}
$exportedFiles = @(Get-ChildItem -LiteralPath $resolvedOutput -File -Recurse)
if ($exportedFiles.Count -eq 0) {
$details = if ($designerLog) { $designerLog.Trim() } else { "No diagnostic log was written." }
throw "1C Designer reported success but exported no files: $details"
}
[pscustomobject]@{
status = "exported"
designer_version = $actualVersion
server_connection = $ServerConnection
extension = $Extension
output_directory = $resolvedOutput
exported_files = $exportedFiles.Count
access_mode = "operating_system_integrated"
} | ConvertTo-Json -Depth 3
}
finally {
if (Test-Path -LiteralPath $logPath -PathType Leaf) {
Remove-Item -LiteralPath $logPath -Force
}
}
+10 -2
View File
@@ -138,7 +138,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
changes = rpc_call(
endpoint_url,
"metadata.saved_state.changes.list",
{"base_id": base_id, "limit": 50, "timeout_seconds": int(timeout)},
{"base_id": base_id, "limit": 50, "timeout_seconds": int(timeout), "include_storage": True},
timeout,
transport=transport,
session_id=session_id,
@@ -169,7 +169,15 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
contextual = rpc_call(
endpoint_url,
"metadata.saved_state.changes.list",
{"base_id": base_id, "limit": 10, "include_context": True, "group_by_context": True, "context_limit": 5, "timeout_seconds": int(timeout)},
{
"base_id": base_id,
"limit": 10,
"include_context": True,
"group_by_context": True,
"context_limit": 5,
"timeout_seconds": int(timeout),
"include_storage": True,
},
timeout,
transport=transport,
session_id=session_id,
+16 -2
View File
@@ -120,7 +120,14 @@ def first_saved_form(endpoint_url: str, base_id: str, timeout: float, *, transpo
result = rpc_call(
endpoint_url,
"metadata.saved_state.forms.search",
{"base_id": base_id, "tables": [table], "limit": 1, "scan_limit": 200, "timeout_seconds": int(timeout)},
{
"base_id": base_id,
"tables": [table],
"limit": 1,
"scan_limit": 200,
"timeout_seconds": int(timeout),
"include_storage": True,
},
timeout,
transport=transport,
session_id=session_id,
@@ -171,6 +178,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
"max_changes": 20,
"max_text_diff_lines": 20,
"timeout_seconds": int(timeout),
"include_storage": True,
}
diff = rpc_call(endpoint_url, "metadata.saved_state.diff", diff_payload, timeout, transport=transport, session_id=session_id)
checks["diff_existing_saved_state"] = {
@@ -193,7 +201,13 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
diff = rpc_call(
endpoint_url,
"metadata.saved_state.diff",
{"base_id": base_id, "table": saved_state_table, "file_name": missing_file, "timeout_seconds": int(timeout)},
{
"base_id": base_id,
"table": saved_state_table,
"file_name": missing_file,
"timeout_seconds": int(timeout),
"include_storage": True,
},
timeout,
transport=transport,
session_id=session_id,
+27 -1
View File
@@ -174,7 +174,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
require(diagnostic_fallback.get("status") == "blocked", "MCP must block low-level diagnostic fallback methods", failures)
require(diagnostic_fallback.get("reason") == "diagnostic_method", "MCP diagnostic fallback block must use diagnostic_method reason", failures)
blocked_effective = rpc_call(
blocked_effective_apply = rpc_call(
endpoint_url,
"metadata.write",
{
@@ -189,11 +189,37 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
transport=transport,
session_id=session_id,
)
apply_gate = None
if blocked_effective_apply.get("schema") == "onec_repository_write_gate.v1":
apply_gate = {
"status": blocked_effective_apply.get("status"),
"error": blocked_effective_apply.get("error"),
}
# Repository/support coordination is allowed to block apply before the
# metadata route is evaluated. Repeat in non-mutating plan mode so the
# smoke still verifies the effective-path routing contract.
blocked_effective = rpc_call(
endpoint_url,
"metadata.write",
{
"base_id": base_id,
"target": {"canonical_path": "Документ.АвансовыйОтчет.Форма.ФормаДокумента.КнопкаЗаписать"},
"mode": "plan",
"edits": [{"property": "Заголовок", "value": "BLOCKED_SMOKE"}],
"timeout_seconds": int(timeout),
},
timeout,
transport=transport,
session_id=session_id,
)
else:
blocked_effective = blocked_effective_apply
checks["blocked_effective_form_path"] = {
"status": blocked_effective.get("status"),
"error": blocked_effective.get("error"),
"routed_method": blocked_effective.get("routed_method"),
"target_kind": blocked_effective.get("target_kind"),
**({"apply_gate": apply_gate} if apply_gate else {}),
}
require(blocked_effective.get("status") == "blocked", "effective canonical path write must be blocked", failures)
require(blocked_effective.get("error") == "write_plan_required", "effective canonical path write must require write plan", failures)
+41 -4
View File
@@ -12,6 +12,17 @@ from urllib.error import HTTPError, URLError
DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011"
DEFAULT_MCP_URL = "http://docker.cin.su:8021"
EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1"
PREFLIGHT_CLASSIFICATION_STATUSES = {
"ready",
"needs_prepare",
"needs_resolution",
"needs_repository_lock",
"blocked",
"blocked_by_support",
"blocked_by_support_live_sql",
"blocked_by_support_rule",
"blocked_support_unknown",
}
def post_json(url: str, payload: dict[str, Any], *, timeout: float, headers: dict[str, str] | None = None) -> tuple[dict[str, str], dict[str, Any]]:
@@ -106,6 +117,10 @@ def require(condition: bool, message: str, failures: list[str]) -> None:
failures.append(message)
def classified_preflight_status(value: Any) -> bool:
return str(value or "") in PREFLIGHT_CLASSIFICATION_STATUSES
def method_names(help_result: dict[str, Any]) -> set[str]:
return {str(item.get("name") or "") for item in help_result.get("methods") or [] if isinstance(item, dict)}
@@ -120,13 +135,24 @@ def first_saved_module_target(endpoint_url: str, base_id: str, timeout: float, *
session_id=session_id,
)
modules = result.get("modules") if isinstance(result.get("modules"), list) else []
targets: list[dict[str, Any]] = []
for module in modules:
if not isinstance(module, dict):
continue
for stream in module.get("streams") or []:
if isinstance(stream, dict) and isinstance(stream.get("write_plan_target"), dict):
return stream["write_plan_target"]
return None
targets.append(stream["write_plan_target"])
# A concrete stream target can be freshness-checked without decoding and
# structurally diffing a whole saved form descriptor. Prefer it so this
# deployment smoke remains bounded on large ConfigCASSave forms.
return next(
(
target
for target in targets
if target.get("stream_index") is not None or "#stream:" in str(target.get("module_ref") or "")
),
targets[0] if targets else None,
)
def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str) -> dict[str, Any]:
@@ -173,7 +199,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
"plan_allowed": (blocked.get("plan") or {}).get("allowed") if isinstance(blocked.get("plan"), dict) else None,
}
require(blocked.get("schema") == "onec_metadata_write_preflight.v1", "preflight must return expected schema", failures)
require(blocked.get("status") in {"blocked", "needs_resolution"}, "effective path preflight must be blocked or need resolution", failures)
require(classified_preflight_status(blocked.get("status")), "effective path preflight must classify readiness or a safety gate", failures)
require(blocked.get("allowed") is False, "effective path preflight must not be allowed", failures)
target = first_saved_module_target(endpoint_url, base_id, timeout, transport=transport, session_id=session_id)
@@ -201,7 +227,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
"writer": (concrete.get("route") or {}).get("writer") if isinstance(concrete.get("route"), dict) else None,
}
require(concrete.get("schema") == "onec_metadata_write_preflight.v1", "concrete preflight must return expected schema", failures)
require(concrete.get("status") in {"ready", "needs_prepare", "blocked"}, "concrete preflight must classify readiness", failures)
require(classified_preflight_status(concrete.get("status")), "concrete preflight must classify readiness or a safety gate", failures)
if concrete.get("status") == "ready":
require(freshness.get("status") == "live_sql_verified", "ready concrete preflight must be live SQL verified", failures)
else:
@@ -230,7 +256,18 @@ def main() -> int:
args = parser.parse_args()
endpoint_url = args.mcp_url if args.transport == "mcp" else args.base_url
try:
report = run_smoke(endpoint_url, args.base_id, args.timeout, transport=args.transport)
except (TimeoutError, URLError, OSError) as exc:
report = {
"schema": "onec_write_preflight_smoke.v1",
"status": "failed",
"endpoint_url": endpoint_url,
"transport": args.transport,
"base_id": args.base_id,
"checks": {},
"failures": [f"transport timeout/error: {type(exc).__name__}: {str(exc)[:300]}"],
}
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+51
View File
@@ -76,3 +76,54 @@ def test_sql_admin_preserves_repository_user_from_settings() -> None:
assert item["repository"]["repository_password_env"] == "REPO_PASSWORD"
assert item["repository"]["infobase_user"] == "designer-user"
assert item["repository"]["infobase_password_env"] == "IB_PASSWORD"
def test_sql_admin_accepts_independent_base_and_extension_policies() -> None:
extension_guid = "11111111-2222-3333-4444-555555555555"
_, item = adapter.sql_admin_validate({
"base_id": "upo_test", "server": "sql", "database": "upo_test", "user": "reader",
"development_layers": {
"base": {"repository": {"mode": "manual"}, "support": {"mode": "rules", "rules": {"Документ.Заказ": "vendor_not_editable"}}},
f"extension:{extension_guid}": {"repository": {"mode": "none"}, "support": {"mode": "none"}},
},
})
assert item["development_layers"]["base"]["repository"]["mode"] == "manual"
assert item["development_layers"]["base"]["support"]["rules"]["Документ.Заказ"] == "vendor_not_editable"
assert item["development_layers"][f"extension:{extension_guid}"]["repository"]["mode"] == "none"
def test_sql_admin_rejects_extension_layer_without_guid() -> None:
with pytest.raises(ValueError, match="extension:<GUID>"):
adapter.sql_admin_validate({
"base_id": "upo_test", "server": "sql", "database": "upo_test", "user": "reader",
"development_layers": {"extension:test": {"repository": {"mode": "none"}, "support": {"mode": "none"}}},
})
def test_agent_can_persist_layer_repository_connection_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
path = tmp_path / "bases.json"
guid = "11111111-2222-3333-4444-555555555555"
monkeypatch.delenv("ONEC_SQL_BASES_JSON", raising=False)
monkeypatch.setenv("ONEC_SQL_BASES_JSON_FILE", str(path))
adapter.sql_admin_save({
"neft": {
"server": "sql", "database": "neft", "user": "reader",
"development_layers": {
"base": {"repository": {"mode": "manual", "connection_state": "configured"}, "support": {"mode": "unknown"}},
f"extension:{guid}": {"repository": {"mode": "unknown", "connection_state": "unknown"}, "support": {"mode": "unknown"}},
},
},
})
missing_confirmation = adapter.call_method("repository.layer.connection.set", {"base_id": "neft", "extension_guid": guid, "connection_state": "not_configured"})
assert missing_confirmation["status"] == "invalid_argument"
result = adapter.call_method("repository.layer.connection.set", {
"base_id": "neft", "extension_guid": guid, "connection_state": "not_configured", "repository_user": "repo-extension-user",
"confirm_repository_connection_change": True,
})
assert result["status"] == "saved"
assert result["repository_status"] == "repository_not_connected"
stored = adapter.sql_admin_load()
repository = stored["neft"]["development_layers"][f"extension:{guid}"]["repository"]
assert repository["connection_state"] == "not_configured"
assert repository["mode"] == "none"
assert repository["repository_user"] == "repo-extension-user"
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "plugins" / "1c"))
from parser.common_command import ( # noqa: E402
common_command_group_guid,
index_common_command_groups,
parse_common_command_tree,
)
from parser.payload import serialize_brace_tree # noqa: E402
def command_tree(group_guid: str) -> dict[str, Any]:
atom = lambda value: {"type": "atom", "value": str(value)}
sequence = lambda *items: {"type": "list", "items": list(items)}
root = sequence()
node = root
path = (1, 1, 2, 7, 1)
for index in path[:-1]:
items = node.setdefault("items", [])
while len(items) <= index:
items.append(sequence())
node = items[index]
items = node.setdefault("items", [])
while len(items) <= path[-1]:
items.append(sequence())
items[path[-1]] = atom(group_guid)
return root
def payload(group_guid: str) -> bytes:
return serialize_brace_tree(command_tree(group_guid)).encode("utf-8")
def test_common_command_group_guid_decodes_observed_relation() -> None:
group_guid = "11111111-1111-1111-1111-111111111111"
tree = parse_common_command_tree(payload(group_guid))
assert common_command_group_guid(tree) == group_guid
def test_common_command_reverse_index_groups_rows_and_reports_gaps() -> None:
group_guid = "11111111-1111-1111-1111-111111111111"
first_guid = "22222222-2222-2222-2222-222222222222"
broken_guid = "33333333-3333-3333-3333-333333333333"
missing_guid = "44444444-4444-4444-4444-444444444444"
commands = [
{"guid": first_guid, "name": "Первая"},
{"guid": broken_guid, "name": "Поврежденная"},
{"guid": missing_guid, "name": "Без файла"},
]
result = index_common_command_groups(
commands,
{
first_guid.upper(): payload(group_guid),
broken_guid: b"not a Config tree",
},
)
assert result["groups"][group_guid] == [{"guid": first_guid, "name": "Первая"}]
assert result["scanned"] == 3
assert result["source_missing"] == 1
assert result["undecodable"] == 1
assert result["unassigned"] == 0
@@ -13,3 +13,12 @@ def test_rest_deploy_refuses_to_drop_service_authentication() -> None:
assert "Reusing the existing REST service token without printing or persisting it" in source
assert "Refusing to deploy an unauthenticated adapter" in source
assert source.index("Ensure-RestServiceToken") < source.index("Invoke-ComposeUp `", source.index("try {"))
def test_deploy_can_forward_complete_saved_state_write_smoke_skip() -> None:
source = (ROOT / "scripts" / "deploy_1c_adapter_stack.ps1").read_text(encoding="utf-8-sig")
assert "[switch]$SkipSavedStateWriteSmoke" in source
assert "[switch]$SkipCodeWriteSavedStateSmoke" in source
assert '$verifyCommand += "-SkipSavedStateWriteSmoke"' in source
assert '$verifyCommand += "-SkipCodeWriteSavedStateSmoke"' in source
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import scripts.check_1c_metadata_kind_fixtures as fixtures # noqa: E402
MANIFEST = ROOT / "config" / "1c_metadata_kind_fixtures.json"
def test_fixture_manifest_is_valid_and_forbids_direct_sql() -> None:
manifest = fixtures.load_json(MANIFEST)
assert fixtures.validate_manifest(manifest) == []
assert manifest["policy"]["direct_sql_write_allowed"] is False
assert manifest["designer"] == {
"required_version": "8.5.1.1236",
"access_mode": "operating_system_integrated_only",
"credential_arguments_allowed": False,
"export_script": "scripts/export_1c_extension_sources.ps1",
}
assert manifest["external_reference"]["usage"] == "external_reference_only_not_vendored"
assert len(manifest["external_reference"]["commit"]) == 40
assert {item["kind"] for item in manifest["fixtures"]} == {
"CalculationRegister",
"Sequence",
"Interface",
}
def test_offline_check_does_not_claim_live_readiness() -> None:
result = fixtures.check_manifest(MANIFEST)
assert result["passed"] is True
assert result["status"] == "manifest_valid"
assert result["counts"] == {"findings": 0, "fixtures": 3, "ready": 0, "gaps": 0}
assert {item["status"] for item in result["fixtures"]} == {"not_checked"}
def test_live_check_distinguishes_ready_missing_and_unconfigured() -> None:
calls: list[tuple[str, dict[str, Any]]] = []
def fake_rpc(_url: str, method: str, payload: dict[str, Any], _timeout: float, _token: str) -> dict[str, Any]:
calls.append((method, payload))
if method == "extension.objects.find":
return {
"status": "ok",
"objects": [
{
"kind": "CalculationRegister",
"name": "tt_РегистрРасчета1",
"guid": "a4dfe8f1-2ce8-43c9-8b41-3deb7238c5d5",
"activation_state": "saved_only",
"extension_guid": "fb26cf42-7609-11f1-828f-005056b0d483",
},
{
"kind": "Catalog",
"name": "tt_Последовательность1",
"guid": "11111111-1111-1111-1111-111111111111",
"activation_state": "saved_only",
},
],
}
return {"status": "ok"}
result = fixtures.check_manifest(MANIFEST, live=True, rpc_call=fake_rpc)
by_kind = {item["kind"]: item for item in result["fixtures"]}
assert by_kind["CalculationRegister"]["status"] == "fixture_ready"
assert by_kind["Sequence"]["status"] == "fixture_missing"
assert by_kind["Interface"]["status"] == "target_not_configured"
assert [item["method"] for item in by_kind["CalculationRegister"]["smoke"]] == [
"metadata.object.get",
"metadata.object.properties",
"metadata.object.attributes",
"metadata.object.related",
]
extension_find_calls = [payload for method, payload in calls if method == "extension.objects.find"]
assert len(extension_find_calls) == 1
calc_find_payload = extension_find_calls[0]
assert "kind" not in calc_find_payload
assert calc_find_payload["use_cache"] is True
assert calc_find_payload["full_scan"] is False
assert calc_find_payload["refresh_cache"] is False
assert result["passed"] is True
assert result["status"] == "gaps_found"
def test_require_ready_fails_when_a_fixture_is_missing() -> None:
def fake_rpc(_url: str, method: str, _payload: dict[str, Any], _timeout: float, _token: str) -> dict[str, Any]:
if method == "metadata.objects.list":
return {"status": "not_found", "objects": []}
return {"status": "not_found", "objects": []}
result = fixtures.check_manifest(
MANIFEST,
live=True,
require_ready=True,
base_overrides={"interface_legacy_base": "legacy_test"},
rpc_call=fake_rpc,
)
assert result["passed"] is False
assert result["counts"]["ready"] == 0
assert result["counts"]["gaps"] == 3
def test_live_check_reuses_extension_scan_transport_failure() -> None:
calls = 0
def fake_rpc(_url: str, _method: str, _payload: dict[str, Any], _timeout: float, _token: str) -> dict[str, Any]:
nonlocal calls
calls += 1
raise TimeoutError("scan timed out")
result = fixtures.check_manifest(MANIFEST, live=True, rpc_call=fake_rpc)
by_kind = {item["kind"]: item for item in result["fixtures"]}
assert calls == 1
assert by_kind["CalculationRegister"]["error"] == "TimeoutError"
assert "probe_error_reused" not in by_kind["CalculationRegister"]
assert by_kind["Sequence"]["error"] == "TimeoutError"
assert by_kind["Sequence"]["probe_error_reused"] is True
def test_manifest_rejects_credential_like_keys(tmp_path: Path) -> None:
value = fixtures.load_json(MANIFEST)
value["fixtures"][0]["password"] = "must-not-be-here"
path = tmp_path / "manifest.json"
path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
result = fixtures.check_manifest(path)
assert result["passed"] is False
assert any(item["code"] == "credential_key_in_manifest" for item in result["findings"])
def test_manifest_requires_safe_designer_access_and_pinned_reference() -> None:
value = fixtures.load_json(MANIFEST)
value["designer"]["access_mode"] = "interactive_credentials"
value["external_reference"]["commit"] = "main"
findings = fixtures.validate_manifest(value)
assert {item["code"] for item in findings} >= {
"unsafe_designer_access_mode",
"invalid_external_reference_commit",
}
def test_designer_export_script_has_no_user_or_credential_parameters() -> None:
script = (ROOT / "scripts" / "export_1c_extension_sources.ps1").read_text(encoding="utf-8")
assert "/WA+" in script
assert "/N" not in script
assert "/P" not in script
assert "UserName" not in script
assert "Credential" not in script
assert "/DumpConfigToFiles" in script
assert "-Extension" in script
+584
View File
@@ -0,0 +1,584 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "plugins" / "1c"))
sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector"))
import adapter_1c_server as adapter # noqa: E402
OBJECT_GUID = "11111111-2222-3333-4444-555555555555"
def atom(value: Any) -> dict[str, Any]:
return {"type": "atom", "value": str(value)}
def string(value: Any) -> dict[str, Any]:
return {"type": "string", "value": str(value)}
def seq(*items: dict[str, Any]) -> dict[str, Any]:
return {"type": "list", "items": list(items)}
def identity_tree(*, synonym: str = "Товары", comment: str = "Комментарий") -> dict[str, Any]:
return seq(
seq(atom(1), atom(0), atom(OBJECT_GUID)),
string("Номенклатура"),
seq(atom(1), string("ru"), string(synonym)),
string(comment),
atom(0),
)
def identity_tree_with_member(*, member_synonym: str = "Артикул товара") -> dict[str, Any]:
return seq(
seq(atom(1), atom(0), atom(OBJECT_GUID)),
string("Номенклатура"),
seq(atom(1), string("ru"), string("Товары")),
string("Комментарий"),
seq(
seq(atom(1), atom(0), atom("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")),
string("Артикул"),
seq(atom(1), string("ru"), string(member_synonym)),
string("Комментарий реквизита"),
),
atom(0),
)
def declared_attribute_tree(*, include_new: bool = False) -> dict[str, Any]:
records = [
seq(
seq(atom(1), atom(0), atom("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")),
string("Артикул"),
seq(atom(1), string("ru"), string("Артикул товара")),
string("Комментарий реквизита"),
atom("type-settings"),
)
]
if include_new:
new_guid = adapter.deterministic_member_guid(
OBJECT_GUID,
"Attribute",
"КодПоставщика",
scope="Catalog.Номенклатура",
)
records.append(
seq(
seq(atom(1), atom(0), atom(new_guid)),
string("КодПоставщика"),
seq(atom(1), string("ru"), string("Код поставщика")),
string("Комментарий реквизита"),
atom("type-settings"),
)
)
return seq(
seq(atom(1), atom(0), atom(OBJECT_GUID)),
string("Номенклатура"),
seq(atom(1), string("ru"), string("Товары")),
string("Комментарий"),
seq(atom(0), atom(len(records)), *records),
atom(0),
)
def prepare_collision() -> dict[str, Any]:
return {
"schema": "onec_saved_state_prepare.v1",
"status": "blocked_target_collision",
"base_id": "base",
"target": {"table": "ConfigSave"},
"object": {
"kind": "Catalog",
"name": "Номенклатура",
"synonym": "Товары",
"guid": OBJECT_GUID,
},
"file_names": ["object.0"],
"counts": {"file_names": 1, "target_rows": 1},
}
def base_payload(**overrides: Any) -> dict[str, Any]:
return {
"base_id": "base",
"ref": "Catalog.Номенклатура",
"property": "synonym",
"value": "Номенклатура товаров",
"allow_saved_state_write": True,
**overrides,
}
def install_common_mocks(monkeypatch, *, tree: dict[str, Any] | None = None) -> None:
monkeypatch.setattr(adapter, "repository_apply_gate", lambda *_args, **_kwargs: None)
monkeypatch.setattr(adapter, "metadata_saved_state_prepare", lambda _payload: prepare_collision())
monkeypatch.setattr(
adapter,
"read_storage_file_bytes",
lambda *_args, **_kwargs: (b"payload", {"database": "db"}, None),
)
monkeypatch.setattr(adapter, "parse_config_tree_from_bytes", lambda _data: tree or identity_tree())
def test_identity_property_target_resolves_existing_synonym_and_comment_paths() -> None:
tree = identity_tree()
synonym = adapter.config_tree_identity_property_target(tree, OBJECT_GUID, "синоним", language="ru")
comment = adapter.config_tree_identity_property_target(tree, OBJECT_GUID, "комментарий")
assert synonym["status"] == "ok"
assert synonym["path"] == "2.2"
assert synonym["current"] == "Товары"
assert synonym["node_type"] == "string"
assert comment["status"] == "ok"
assert comment["path"] == "3"
assert comment["current"] == "Комментарий"
def test_member_identity_resolves_full_public_path_by_semantic_category(monkeypatch) -> None:
member_guid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
tree = identity_tree_with_member()
monkeypatch.setattr(
adapter,
"nested_metadata_semantic_index",
lambda *_args, **_kwargs: {
member_guid: {
"category": "Attribute",
"ref": "Catalog.Номенклатура.Attribute.Артикул",
}
},
)
resolved = adapter.config_tree_member_identity_resolve(
tree,
parent_guid=OBJECT_GUID,
parent_kind="Catalog",
parent_name="Номенклатура",
member_path=["Реквизит", "Артикул"],
member_name="Артикул",
)
assert resolved["status"] == "ok"
assert resolved["member"]["guid"] == member_guid
assert resolved["member"]["kind"] == "Attribute"
assert resolved["member"]["ref"] == "Catalog.Номенклатура.Attribute.Артикул"
def test_declared_member_record_resolves_append_container() -> None:
resolved = adapter.config_tree_declared_record_for_guid(
declared_attribute_tree(),
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
)
assert resolved["status"] == "ok"
assert resolved["record_path"] == "4.2"
assert resolved["parent_path"] == "4"
def test_object_property_plan_uses_exact_guid_path_and_compare_guard(monkeypatch) -> None:
install_common_mocks(monkeypatch)
captured: dict[str, Any] = {}
def fake_propose(payload: dict[str, Any]) -> dict[str, Any]:
captured.update(payload)
return {
"status": "accepted_for_review",
"source": payload["source"],
"original": {"sha1": "old"},
"encoded": {"sha1": "new"},
"edits": payload["edits"],
"validation": {"status": "ok", "mode": "path_preserve_format"},
}
monkeypatch.setattr(adapter, "changes_propose", fake_propose)
result = adapter.metadata_object_property_write(base_payload(expected_old="Товары"))
assert result["status"] == "planned"
assert result["object"]["ref"] == "Catalog.Номенклатура"
assert result["property"] == {
"name": "synonym",
"language": "ru",
"current": "Товары",
"requested": "Номенклатура товаров",
}
assert captured["source"]["table"] == "ConfigSave"
assert captured["edits"] == [
{
"path": "2.2",
"value": "Номенклатура товаров",
"node_type": "string",
"expected_old": "Товары",
"property": "synonym",
}
]
assert "source" not in result["proposal"]
def test_object_property_plan_returns_name_first_prepare_call(monkeypatch) -> None:
monkeypatch.setattr(adapter, "repository_apply_gate", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
adapter,
"metadata_saved_state_prepare",
lambda _payload: {
"status": "plan_ready",
"base_id": "base",
"target": {"table": "ConfigSave"},
"source": {"table": "Config"},
"object": {"kind": "Catalog", "name": "Номенклатура", "guid": OBJECT_GUID},
"file_names": ["object.0"],
"counts": {"source_rows": 1, "target_rows": 0},
},
)
result = adapter.metadata_object_property_write(base_payload())
assert result["status"] == "needs_prepare"
assert result["next_call"] == {
"method": "metadata.saved_state.prepare",
"params": {
"base_id": "base",
"layer": "base_saved_state",
"ref": "Catalog.Номенклатура",
"kind": "Catalog",
"name": "Номенклатура",
"mode": "plan",
},
}
assert "table" not in result["next_call"]["params"]
assert "file_name" not in result["next_call"]["params"]
def test_object_property_plan_writes_existing_member_by_name(monkeypatch) -> None:
install_common_mocks(monkeypatch, tree=identity_tree_with_member())
captured: dict[str, Any] = {}
def fake_propose(payload: dict[str, Any]) -> dict[str, Any]:
captured.update(payload)
return {
"status": "accepted_for_review",
"source": payload["source"],
"original": {"sha1": "old"},
"encoded": {"sha1": "new"},
"edits": payload["edits"],
"validation": {"status": "ok", "mode": "path_preserve_format"},
}
monkeypatch.setattr(adapter, "changes_propose", fake_propose)
result = adapter.metadata_object_property_write(
base_payload(
member_name="Артикул",
value="Код товара поставщика",
expected_old="Артикул товара",
)
)
assert result["status"] == "planned"
assert result["member"]["name"] == "Артикул"
assert result["member"]["guid"] == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
assert captured["edits"][0]["path"] == "4.2.2"
assert captured["edits"][0]["expected_old"] == "Артикул товара"
def test_object_property_apply_verify_and_rollback(monkeypatch) -> None:
install_common_mocks(monkeypatch)
reads = iter([(b"old", {"database": "db"}, None), (b"new", {"database": "db"}, None)])
monkeypatch.setattr(adapter, "read_storage_file_bytes", lambda *_args, **_kwargs: next(reads))
monkeypatch.setattr(
adapter,
"parse_config_tree_from_bytes",
lambda data: identity_tree(synonym="Номенклатура товаров" if data == b"new" else "Товары"),
)
monkeypatch.setattr(
adapter,
"changes_propose",
lambda payload: {
"status": "accepted_for_review",
"source": payload["source"],
"original": {"sha1": "old"},
"encoded": {"sha1": "new", "payload_hex": "00"},
"edits": payload["edits"],
"validation": {"status": "ok", "mode": "path_preserve_format"},
},
)
monkeypatch.setattr(
adapter,
"storage_saved_state_apply_proposal",
lambda _payload: {"status": "applied", "applied": True, "backup": {"backup_id": "backup-1"}},
)
monkeypatch.setattr(
adapter,
"storage_saved_state_rollback",
lambda payload: {"status": "applied", "applied": True, "backup_id": payload["backup_id"]},
)
result = adapter.metadata_object_property_write(
base_payload(
execution_mode="apply_and_rollback",
allow_sql_saved_state_apply=True,
allow_sql_saved_state_rollback=True,
)
)
assert result["status"] == "verified_and_rolled_back"
assert result["applied"] is True
assert result["rolled_back"] is True
assert result["semantic_verification"] == {
"status": "ok",
"expected": "Номенклатура товаров",
"actual": "Номенклатура товаров",
}
def test_object_property_write_rejects_rename_and_expected_old_drift(monkeypatch) -> None:
install_common_mocks(monkeypatch)
rename = adapter.call_method(
adapter.OBJECT_PROPERTY_WRITE_METHOD,
base_payload(property="name", value="НовоеИмя"),
)
drift = adapter.metadata_object_property_write(base_payload(expected_old="Старое значение"))
assert rename["status"] == "invalid_argument"
assert rename["argument"] == "property"
assert drift["status"] == "precondition_failed"
assert drift["error"] == "expected_old_mismatch"
def test_metadata_write_routes_object_property_by_public_selector(monkeypatch) -> None:
monkeypatch.setattr(adapter, "repository_apply_gate", lambda *_args, **_kwargs: None)
captured: dict[str, Any] = {}
def fake_write(payload: dict[str, Any]) -> dict[str, Any]:
captured.update(payload)
return {"status": "planned", "property": {"name": payload["property"]}}
monkeypatch.setattr(adapter, "metadata_object_property_write", fake_write)
result = adapter.metadata_write(
{
"base_id": "base",
"target": {
"area": "object",
"kind": "Catalog",
"name": "Номенклатура",
"property": "comment",
},
"value": "Пояснение",
"execution_mode": "plan",
}
)
assert result["status"] == "planned"
assert result["routed_method"] == adapter.OBJECT_PROPERTY_WRITE_METHOD
assert captured["kind"] == "Catalog"
assert captured["name"] == "Номенклатура"
assert captured["property"] == "comment"
assert captured["value"] == "Пояснение"
assert captured["allow_saved_state_write"] is True
def test_object_member_add_plan_clones_attribute_and_generates_guid(monkeypatch) -> None:
install_common_mocks(monkeypatch, tree=declared_attribute_tree())
monkeypatch.setattr(
adapter,
"nested_metadata_semantic_index",
lambda *_args, **_kwargs: {
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": {
"category": "Attribute",
"ref": "Catalog.Номенклатура.Attribute.Артикул",
}
},
)
captured: dict[str, Any] = {}
def fake_propose(payload: dict[str, Any]) -> dict[str, Any]:
captured.update(payload)
return {
"status": "accepted_for_review",
"source": payload["source"],
"original": {"sha1": "old"},
"encoded": {"sha1": "new"},
"edits": [{"mode": "structural_append_child_preserve_format"}],
"validation": {"status": "ok", "mode": "path_preserve_format"},
}
monkeypatch.setattr(adapter, "changes_propose", fake_propose)
result = adapter.metadata_object_member_add(
{
"base_id": "base",
"template_member_ref": "Catalog.Номенклатура.Attribute.Артикул",
"new_member_name": "КодПоставщика",
"new_member_synonym": "Код поставщика",
"allow_saved_state_write": True,
"execution_mode": "plan",
}
)
new_guid = adapter.deterministic_member_guid(
OBJECT_GUID,
"Attribute",
"КодПоставщика",
scope="Catalog.Номенклатура",
)
append = captured["edits"][0]["append_child"]
cloned_identities = adapter.config_tree_identity_records(append["node"])
assert result["status"] == "planned"
assert result["requested_member"]["guid"] == new_guid
assert result["requested_member"]["ref"] == "Catalog.Номенклатура.Attribute.КодПоставщика"
assert result["container"] == {"ref": "Catalog.Номенклатура", "scope": "object"}
assert append["parent_path"] == "4"
assert cloned_identities[new_guid]["name"] == "КодПоставщика"
assert cloned_identities[new_guid]["synonyms"]["ru"] == "Код поставщика"
def test_object_member_add_scopes_tabular_column_guid_and_ref(monkeypatch) -> None:
install_common_mocks(monkeypatch, tree=declared_attribute_tree())
monkeypatch.setattr(
adapter,
"nested_metadata_semantic_index",
lambda *_args, **_kwargs: {
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": {
"category": "Attribute",
"ref": "Catalog.Номенклатура.TabularSection.Единицы.Attribute.Артикул",
}
},
)
monkeypatch.setattr(
adapter,
"changes_propose",
lambda payload: {
"status": "accepted_for_review",
"source": payload["source"],
"original": {"sha1": "old"},
"encoded": {"sha1": "new"},
"edits": [{"mode": "structural_append_child_preserve_format"}],
"validation": {"status": "ok", "mode": "path_preserve_format"},
},
)
result = adapter.metadata_object_member_add(
{
"base_id": "base",
"template_member_ref": "Catalog.Номенклатура.TabularSection.Единицы.Attribute.Артикул",
"new_member_name": "КодПоставщика",
"allow_saved_state_write": True,
"execution_mode": "plan",
}
)
expected_guid = adapter.deterministic_member_guid(
OBJECT_GUID,
"Attribute",
"КодПоставщика",
scope="Catalog.Номенклатура.TabularSection.Единицы",
)
assert result["status"] == "planned"
assert result["container"] == {
"ref": "Catalog.Номенклатура.TabularSection.Единицы",
"scope": "tabular_section",
}
assert result["requested_member"]["ref"] == (
"Catalog.Номенклатура.TabularSection.Единицы.Attribute.КодПоставщика"
)
assert result["requested_member"]["guid"] == expected_guid
def test_object_member_add_apply_verify_and_rollback(monkeypatch) -> None:
install_common_mocks(monkeypatch, tree=declared_attribute_tree())
reads = iter([(b"old", {"database": "db"}, None), (b"new", {"database": "db"}, None)])
monkeypatch.setattr(adapter, "read_storage_file_bytes", lambda *_args, **_kwargs: next(reads))
monkeypatch.setattr(
adapter,
"parse_config_tree_from_bytes",
lambda data: declared_attribute_tree(include_new=data == b"new"),
)
monkeypatch.setattr(
adapter,
"nested_metadata_semantic_index",
lambda *_args, **_kwargs: {
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": {
"category": "Attribute",
"ref": "Catalog.Номенклатура.Attribute.Артикул",
}
},
)
monkeypatch.setattr(
adapter,
"changes_propose",
lambda payload: {
"status": "accepted_for_review",
"source": payload["source"],
"original": {"sha1": "old"},
"encoded": {"sha1": "new", "payload_hex": "00"},
"edits": [{"mode": "structural_append_child_preserve_format"}],
"validation": {"status": "ok", "mode": "path_preserve_format"},
},
)
monkeypatch.setattr(
adapter,
"storage_saved_state_apply_proposal",
lambda _payload: {"status": "applied", "applied": True, "backup": {"backup_id": "backup-add"}},
)
monkeypatch.setattr(
adapter,
"storage_saved_state_rollback",
lambda _payload: {"status": "applied", "applied": True},
)
result = adapter.metadata_object_member_add(
{
"base_id": "base",
"template_member_ref": "Catalog.Номенклатура.Attribute.Артикул",
"new_member_name": "КодПоставщика",
"new_member_synonym": "Код поставщика",
"allow_saved_state_write": True,
"allow_sql_saved_state_apply": True,
"allow_sql_saved_state_rollback": True,
"execution_mode": "apply_and_rollback",
}
)
assert result["status"] == "verified_and_rolled_back"
assert result["semantic_verification"]["status"] == "ok"
assert result["rolled_back"] is True
def test_metadata_write_routes_add_attribute(monkeypatch) -> None:
monkeypatch.setattr(adapter, "repository_apply_gate", lambda *_args, **_kwargs: None)
captured: dict[str, Any] = {}
def fake_add(payload: dict[str, Any]) -> dict[str, Any]:
captured.update(payload)
return {"status": "planned"}
monkeypatch.setattr(adapter, "metadata_object_member_add", fake_add)
result = adapter.metadata_write(
{
"base_id": "base",
"target": {
"area": "object",
"operation": "add_attribute",
"template_member_ref": "Catalog.Номенклатура.Attribute.Артикул",
"new_member_name": "КодПоставщика",
},
"mode": "plan",
}
)
assert result["status"] == "planned"
assert result["routed_method"] == adapter.OBJECT_MEMBER_ADD_METHOD
assert captured["template_member_ref"] == "Catalog.Номенклатура.Attribute.Артикул"
assert captured["new_member_name"] == "КодПоставщика"
assert captured["allow_saved_state_write"] is True
File diff suppressed because it is too large Load Diff
+461 -3
View File
@@ -4,6 +4,8 @@ import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector"))
@@ -33,6 +35,21 @@ def configured_base(monkeypatch, tmp_path: Path, backend: str = "karman_bridge")
monkeypatch.setenv("ONEC_REPOSITORY_STATE_FILE", str(tmp_path / "locks.json"))
def configured_layers(monkeypatch, tmp_path: Path) -> None:
config = {
"base": {
"server": "sql", "database": "db", "user": "reader",
"development_layers": {
"base": {"repository": {"mode": "manual"}, "support": {"mode": "rules", "rules": {"Документ.Заказ": "vendor_not_editable", "Документ.Заказ.Форма.ФормаДокумента": "vendor_editable_support_preserved"}}},
"extension:11111111-2222-3333-4444-555555555555": {"repository": {"mode": "none"}, "support": {"mode": "none"}},
"extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": {"repository": {"mode": "manual"}, "support": {"mode": "editable"}},
},
}
}
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(config))
monkeypatch.setenv("ONEC_REPOSITORY_STATE_FILE", str(tmp_path / "locks.json"))
def test_repository_backend_and_endpoint_come_only_from_base_settings(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
config, error = repository_control.repository_config("base")
@@ -56,7 +73,11 @@ def test_http_runner_profile_does_not_require_local_designer_or_credentials(monk
def test_lock_plan_maps_child_metadata_to_development_owner() -> None:
result = repository_control.lock_plan({"object": "РегистрСведений.Настройки.Реквизит.Код"})
assert result["status"] == "ready"
assert result["scope_status"] == "resolved"
assert result["lock_objects"] == ["РегистрСведений.Настройки"]
assert result["native_lock_state"] == "unknown"
assert result["native_lock_verification"] == "not_available_via_infobase_sql"
assert result["capture_required"] is None
def test_manual_lock_confirmation_is_scoped_and_not_reported_as_verified(monkeypatch, tmp_path: Path) -> None:
@@ -67,10 +88,16 @@ def test_manual_lock_confirmation_is_scoped_and_not_reported_as_verified(monkeyp
planned = repository_control.lock_plan({"base_id": "base", "object": "Справочник.Товары.МодульОбъекта"})
assert planned["workflow"] == "manual"
assert planned["next_method"] == "repository.lock.request"
assert planned["user_action"]["action"] == "confirm_lock_in_configurator"
assert "уже захвачены" in planned["user_action"]["message"]
assert planned["user_action"]["objects"] == ["Справочник.Товары"]
requested = repository_control.create_lock_request({"base_id": "base", "object": "Справочник.Товары"})
confirmed = repository_control.confirm_manual_lock({"base_id": "base", "request_id": requested["request_id"], "user_confirmed_locked": True})
confirmed = repository_control.confirm_manual_lock({"base_id": "base", "request_id": requested["request_id"], "user_confirmed_locked": True, "confirmed_repository_user": "repo-user"})
assert confirmed["status"] == "manual_confirmed"
assert confirmed["capture_state"] == "manually_confirmed"
assert confirmed["native_lock_state"] == "unknown"
assert confirmed["native_lock_verification"] == "not_available_via_infobase_sql"
assert confirmed["automatically_verified"] is False
verified = repository_control.verify({"lock_session_id": confirmed["lock_session_id"]})
assert verified["status"] == "manual_confirmation_unverified"
@@ -88,17 +115,32 @@ def test_manual_lock_request_stays_pending_until_user_confirms_exact_saved_scope
assert requested["status"] == "pending_user_lock"
assert requested["objects"] == ["РегистрСведений.Настройки"]
assert requested["automatically_locked"] is False
assert requested["next_call"] == {
"method": "repository.lock.confirm",
"params": {"base_id": "base", "request_id": requested["request_id"], "user_confirmed_locked": True},
}
pending = repository_control.lock_request_status({"request_id": requested["request_id"]})
assert pending["status"] == "pending_user_lock"
assert pending["next_call"] == requested["next_call"]
incomplete = repository_control.confirm_manual_lock({"base_id": "base", "request_id": requested["request_id"]})
assert incomplete["status"] == "confirmation_required"
assert incomplete["error"] == "user_confirmed_locked_required"
assert incomplete["required_fields"] == ["base_id", "request_id", "user_confirmed_locked"]
assert incomplete["next_call"] == requested["next_call"]
assert incomplete["sql_resolution"] == []
confirmed = repository_control.confirm_manual_lock({
"base_id": "base",
"request_id": requested["request_id"],
"objects": ["Справочник.Подмена"],
"user_confirmed_locked": True,
"confirmed_repository_user": "repo-user",
})
assert confirmed["objects"] == ["РегистрСведений.Настройки"]
assert confirmed["automatically_verified"] is False
assert confirmed["write_context"]["lock_session_id"] == confirmed["lock_session_id"]
assert confirmed["write_context"]["repository_object"] == "РегистрСведений.Настройки"
completed = repository_control.lock_request_status({"request_id": requested["request_id"]})
assert completed["status"] == "confirmed_by_user"
@@ -148,6 +190,122 @@ def test_adapter_resolves_lock_request_object_against_live_sql(monkeypatch) -> N
assert normalized["objects"] == ["РегистрСведений.Настройки"]
def test_form_lock_scope_is_resolved_before_plan_request_and_confirmation(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
def fake_get_object(kind, name, **kwargs):
assert kind == "Document"
assert name == "тл_Планировщик"
return {"status": "ok", "object": {"kind": "Document", "name": "тл_Планировщик", "guid": "owner-guid"}}
monkeypatch.setattr(adapter_1c_server, "get_object", fake_get_object)
def fake_metadata_object_forms(payload):
assert "extension_guid" not in payload
return {"status": "ok", "forms": [{"guid": form_guid, "name": "ФормаДокументаНовая"}]}
monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", fake_metadata_object_forms)
reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая"
planned = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "objects": [reference]})
assert planned["status"] == "ready"
assert planned["lock_objects"] == [reference]
resolution = planned["sql_resolution"][0]
assert resolution["requested_object"] == reference
assert resolution["resolved_object"] == reference
assert resolution["form_guid"] == form_guid
assert resolution["owner"] == {"kind": "Document", "name": "тл_Планировщик", "guid": "owner-guid"}
assert resolution["table"] == "Config"
assert resolution["source"] == "live_sql_form_owner"
assert resolution["attempts"][-1] == {"stage": "form", "table": "Config", "status": "ok"}
request = adapter_1c_server.call_method("repository.lock.request", {"base_id": "base", "objects": [reference]})
assert request["status"] == "pending_user_lock"
assert request["sql_resolution"] == planned["sql_resolution"]
confirmed = repository_control.confirm_manual_lock({
"base_id": "base", "request_id": request["request_id"], "user_confirmed_locked": True,
"confirmed_repository_user": "repo-user",
})
assert confirmed["status"] == "manual_confirmed"
assert repository_control.write_gate({"base_id": "base", "lock_session_id": confirmed["lock_session_id"], "repository_object": reference})["allowed"] is True
mismatch = repository_control.write_gate({"base_id": "base", "lock_session_id": confirmed["lock_session_id"], "repository_object": "Document.тл_Планировщик.Form.Другая"})
assert mismatch["status"] == "blocked_repository_scope_mismatch"
assert mismatch["error"] == "lock_object_mismatch"
@pytest.mark.parametrize("kind", ["Catalog", "DataProcessor", "Report"])
def test_owned_form_lock_resolver_supports_other_owner_kinds(monkeypatch, tmp_path: Path, kind: str) -> None:
configured_base(monkeypatch, tmp_path)
monkeypatch.setattr(adapter_1c_server, "get_object", lambda actual_kind, name, **kwargs: {"status": "ok", "object": {"kind": actual_kind, "name": name, "guid": "owner-guid"}})
monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", lambda payload: {"status": "ok", "forms": [{"guid": "11111111-2222-3333-4444-555555555555", "name": "Основная"}]})
result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": f"{kind}.Объект.Form.Основная"})
assert result["status"] == "ready"
assert result["lock_objects"] == [f"{kind}.Объект.Form.Основная"]
def test_owned_extension_form_lock_discovers_its_sql_layer(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
extension_guid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
reference = "Document.Заказ.Form.Основная"
calls: list[str | None] = []
def fake_resolve(**kwargs):
guid = kwargs.get("extension_guid")
calls.append(guid)
if guid != extension_guid:
return None, {"status": "not_found", "error": "repository_form_not_found_in_sql"}
return {
"requested_object": reference,
"resolved_object": reference,
"form_guid": "11111111-2222-3333-4444-555555555555",
"owner": {"kind": "Document", "name": "Заказ", "guid": "owner-guid"},
"table": "ConfigCAS",
"source": "live_sql_form_owner",
"extension_guid": extension_guid,
}, None
monkeypatch.setattr(adapter_1c_server, "repository_resolve_owned_form_sql", fake_resolve)
monkeypatch.setattr(adapter_1c_server, "list_extensions", lambda payload: {
"status": "ok", "extensions": [{"guid": extension_guid}],
})
result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": reference})
assert result["status"] == "ready"
assert result["layer_id"] == f"extension:{extension_guid}"
assert result["sql_resolution"][0]["source"] == "live_sql_extension_form_owner"
assert calls == [None, extension_guid]
def test_form_guid_lock_scope_uses_cached_owner_only_after_live_validation(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
monkeypatch.setattr(adapter_1c_server, "metadata_form_owner_cache_lookup", lambda *args, **kwargs: {"owner": {"kind": "Document", "name": "тл_Планировщик"}, "form": {"name": "ФормаДокументаНовая"}})
monkeypatch.setattr(adapter_1c_server, "get_object", lambda kind, name, **kwargs: {"status": "ok", "object": {"kind": kind, "name": name, "guid": "owner-guid"}})
monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", lambda payload: {"status": "ok", "forms": [{"guid": guid, "name": "ФормаДокументаНовая"}]})
result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": f"Form.{guid}"})
assert result["status"] == "ready"
assert result["lock_objects"] == ["Document.тл_Планировщик.Form.ФормаДокументаНовая"]
def test_form_guid_lock_scope_falls_back_to_live_sql_owner_scan(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
monkeypatch.setattr(adapter_1c_server, "metadata_form_owner_cache_lookup", lambda *args, **kwargs: None)
monkeypatch.setattr(adapter_1c_server, "list_objects", lambda kind, **kwargs: {"status": "ok", "objects": [{"kind": "Document", "name": "тл_Планировщик", "guid": "owner-guid"}] if kind == "Document" else []})
monkeypatch.setattr(adapter_1c_server, "metadata_object_related", lambda payload: {"status": "ok", "related": [{"category": "Form", "identity": {"guid": guid, "name": "ФормаДокументаНовая"}}]})
monkeypatch.setattr(adapter_1c_server, "get_object", lambda kind, name, **kwargs: {"status": "ok", "object": {"kind": kind, "name": name, "guid": "owner-guid"}})
monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", lambda payload: {"status": "ok", "forms": [{"guid": guid, "name": "ФормаДокументаНовая"}]})
result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": f"Form.{guid}"})
assert result["status"] == "ready"
assert result["lock_objects"] == ["Document.тл_Планировщик.Form.ФормаДокументаНовая"]
def test_missing_owned_form_is_rejected_by_lock_plan(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
monkeypatch.setattr(adapter_1c_server, "get_object", lambda kind, name, **kwargs: {"status": "ok", "object": {"kind": kind, "name": name, "guid": "owner-guid"}})
monkeypatch.setattr(adapter_1c_server, "metadata_object_forms", lambda payload: {"status": "ok", "forms": []})
result = adapter_1c_server.call_method("repository.lock.plan", {"base_id": "base", "object": "Document.тл_Планировщик.Form.НетТакой"})
assert result["status"] == "not_found"
assert result["error"] == "repository_form_not_found_in_sql"
def test_structural_lock_plan_requires_confirmation() -> None:
result = repository_control.lock_plan({"operation": "delete", "object": "Справочник.Склады"})
assert result["status"] == "needs_confirmation"
@@ -160,11 +318,110 @@ def test_write_gate_requires_adapter_lock_for_configured_base(monkeypatch, tmp_p
def test_adapter_dispatch_exposes_repository_status(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path, backend="direct")
monkeypatch.setenv("ONEC_ADAPTER_ENABLE_EXTERNAL_1C", "true")
result = adapter_1c_server.call_method("repository.status", {"base_id": "base"})
assert result["status"] == "configured"
assert result["repository"]["backend"] == "direct"
def test_repository_confirmation_help_exposes_machine_readable_contract() -> None:
result = adapter_1c_server.call_method("help.methods", {"method": "repository.lock.confirm"})
schema = result["methods"][0]["input_schema"]
assert schema["required"] == ["base_id", "request_id", "user_confirmed_locked"]
assert schema["example"]["user_confirmed_locked"] is True
assert "lock_session_id" in schema["notes"][-1]
def test_sql_only_repository_status_does_not_claim_repository_connection(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
monkeypatch.delenv("ONEC_ADAPTER_ENABLE_EXTERNAL_1C", raising=False)
result = repository_control.status({"base_id": "base", "probe": True})
assert result["status"] == "manual_confirmation_required"
assert result["connected"] is False
assert result["available"] is None
assert result["sql_lock_evidence"]["native_lock_state"] == "unknown"
assert result["sql_lock_evidence"]["excluded_sources"]["_ConfigChngR"] == "exchange_plan_change_registration"
def test_repository_policy_is_selected_per_base_or_extension_guid(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
base = repository_control.write_gate({"base_id": "base"})
no_repository = repository_control.write_gate({"base_id": "base", "extension_guid": "11111111-2222-3333-4444-555555555555"})
extension = repository_control.write_gate({"base_id": "base", "extension_guid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"})
assert base["status"] == "needs_repository_lock"
assert base["layer_id"] == "base"
assert no_repository == {"required": False, "allowed": True, "status": "repository_not_connected", "layer_id": "extension:11111111-2222-3333-4444-555555555555"}
assert extension["status"] == "needs_repository_lock"
assert extension["layer_id"] == "extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
missing = repository_control.write_gate({"base_id": "base", "extension_guid": "99999999-2222-3333-4444-555555555555"})
assert missing["status"] == "blocked_repository_configuration"
assert missing["problem"]["status"] == "layer_not_configured"
def test_repository_connection_state_is_per_resolved_configuration_layer(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
values = json.loads(__import__("os").environ["ONEC_SQL_BASES_JSON"])
layers = values["base"]["development_layers"]
layers["base"]["repository"].update({"mode": "automatic", "connection_state": "unavailable", "repository_user": "repo-user", "runner": {"kind": "http", "url": "http://runner.invalid"}})
layers["extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]["repository"]["connection_state"] = "unknown"
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(values))
base = repository_control.lock_plan({"base_id": "base", "object": "ОбщийМодуль.Тест"})
assert base["workflow"] == "manual"
assert base["status"] == "ready"
assert repository_control.status({"base_id": "base"})["status"] == "manual_confirmation_required"
extension = repository_control.write_gate({
"base_id": "base",
"target": {"origin": {"extension": {"guid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}}},
})
assert extension["layer_id"] == "extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
assert extension["status"] == "blocked_repository_connection_unknown"
def test_manual_confirmation_uses_repository_user_saved_for_the_layer(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
values = json.loads(__import__("os").environ["ONEC_SQL_BASES_JSON"])
values["base"]["repository"].update({"lock_mode": "manual", "connection_state": "unavailable"})
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(values))
request = repository_control.create_lock_request({"base_id": "base", "object": "Справочник.Товары"})
assert request["repository_user"] == "repo-user"
assert request["repository_user_source"] == "layer_connection_setting"
confirmed_without_repeating_user = repository_control.confirm_manual_lock({
"base_id": "base", "request_id": request["request_id"], "user_confirmed_locked": True,
})
assert confirmed_without_repeating_user["status"] == "manual_confirmed"
assert confirmed_without_repeating_user["repository_user"] == "repo-user"
request = repository_control.create_lock_request({"base_id": "base", "object": "Справочник.Товары"})
mismatch = repository_control.confirm_manual_lock({"base_id": "base", "request_id": request["request_id"], "user_confirmed_locked": True, "confirmed_repository_user": "other"})
assert mismatch["error"] == "repository_user_mismatch"
confirmed = repository_control.confirm_manual_lock({"base_id": "base", "request_id": request["request_id"], "user_confirmed_locked": True, "confirmed_repository_user": "repo-user"})
assert confirmed["repository_user"] == "repo-user"
def test_repository_confirmation_cannot_cross_extension_layers(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
guid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
request = repository_control.create_lock_request({"base_id": "base", "extension_guid": guid, "object": "ОбщийМодуль.МодульРасширения"})
confirmed = repository_control.confirm_manual_lock({"base_id": "base", "extension_guid": guid, "request_id": request["request_id"], "user_confirmed_locked": True, "confirmed_repository_user": "operator"})
assert repository_control.write_gate({"base_id": "base", "extension_guid": guid, "lock_session_id": confirmed["lock_session_id"], "repository_object": "ОбщийМодуль.МодульРасширения"})["allowed"] is True
mismatch = repository_control.write_gate({"base_id": "base", "lock_session_id": confirmed["lock_session_id"], "repository_object": "ОбщийМодуль.МодульРасширения"})
assert mismatch["status"] == "blocked_repository_lock_session"
def test_support_gate_uses_exact_target_scope_independently_from_repository_scope(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
parent = repository_control.support_gate({"base_id": "base", "canonical_path": "Документ.Заказ"})
form = repository_control.support_gate({"base_id": "base", "canonical_path": "Документ.Заказ.Форма.ФормаДокумента"})
attribute = repository_control.support_gate({"base_id": "base", "canonical_path": "Документ.Заказ.Реквизит.Комментарий"})
extension = repository_control.support_gate({"base_id": "base", "extension_guid": "11111111-2222-3333-4444-555555555555", "canonical_path": "ОбщийМодуль.Тест"})
assert parent["allowed"] is False
assert form["allowed"] is True
assert form["matched_scope"] == "Документ.Заказ.Форма.ФормаДокумента"
assert attribute["allowed"] is False
assert extension["status"] == "not_on_support"
def test_write_preflight_reports_repository_lock_requirement(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
monkeypatch.setattr(
@@ -186,6 +443,208 @@ def test_write_preflight_reports_repository_lock_requirement(monkeypatch, tmp_pa
assert result["repository"]["backend"] == "karman_bridge"
def test_write_preflight_uses_resolved_origin_layer_not_a_client_selector(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
values = json.loads(__import__("os").environ["ONEC_SQL_BASES_JSON"])
values["base"]["development_layers"]["extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]["repository"]["connection_state"] = "unknown"
monkeypatch.setenv("ONEC_SQL_BASES_JSON", json.dumps(values))
monkeypatch.setattr(
adapter_1c_server,
"metadata_write_plan",
lambda payload: {
"allowed": True, "status": "ready", "path_resolution": {},
"target": {"origin": {"extension": {"guid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}}},
"route": {}, "required_guards": [], "problems": [],
},
)
monkeypatch.setattr(adapter_1c_server, "metadata_write_preflight_saved_target", lambda payload, plan: {})
monkeypatch.setattr(adapter_1c_server, "live_sql_support_gate", lambda payload: {"allowed": True, "status": "ok"})
result = adapter_1c_server.metadata_write_preflight({"base_id": "base"})
assert result["repository"]["layer_id"] == "extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
assert result["status"] == "blocked_repository_connection_unknown"
def test_form_module_write_gate_context_is_resolved_from_live_sql(monkeypatch) -> None:
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая"
monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({
"resolved_object": reference,
"form_guid": form_guid,
"owner": {"kind": "Document", "name": "тл_Планировщик", "guid": "owner-guid"},
}, None))
context = adapter_1c_server.resolve_write_gate_context({
"base_id": "neft", "module_id": f"Config:{form_guid}.0",
"repository_object": "Document.Другая.Форма.Другая",
})
assert context["repository_object"] == reference
assert context["support_object_guid"] == form_guid
assert context["owner_resolution"]["layer_id"] == "base"
def test_confirm_write_context_can_be_forwarded_under_repository_lock() -> None:
context, error = adapter_1c_server.normalize_repository_write_context({
"base_id": "neft",
"repository_lock": {
"lock_session_id": "rlock-test",
"repository_object": "Document.тл_Планировщик.Form.ФормаДокументаНовая",
"layer_id": "base",
},
}, "metadata.write.preflight")
assert error is None
assert context["lock_session_id"] == "rlock-test"
assert context["repository_object"] == "Document.тл_Планировщик.Form.ФормаДокументаНовая"
assert context["layer_id"] == "base"
_context, conflict = adapter_1c_server.normalize_repository_write_context({
"lock_session_id": "rlock-one",
"write_context": {"lock_session_id": "rlock-two"},
}, "code.write")
assert conflict["error"] == "repository_lock_context_conflict"
def test_preflight_uses_form_module_context_for_both_gates(monkeypatch) -> None:
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая"
monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({
"resolved_object": reference, "form_guid": form_guid,
"owner": {"kind": "Document", "name": "тл_Планировщик"},
}, None))
monkeypatch.setattr(adapter_1c_server, "metadata_write_plan", lambda payload: {
"allowed": True, "status": "ready", "path_resolution": {}, "target": {},
"route": {}, "required_guards": [], "problems": [],
})
monkeypatch.setattr(adapter_1c_server, "metadata_write_preflight_saved_target", lambda payload, plan: {})
captured: dict[str, dict] = {}
def fake_repository_gate(payload):
captured["repository"] = dict(payload)
return {"allowed": True, "status": "ready"}
def fake_support_gate(payload):
captured["support"] = dict(payload)
return {"allowed": True, "status": "ready"}
monkeypatch.setattr(repository_control, "write_gate", fake_repository_gate)
monkeypatch.setattr(adapter_1c_server, "live_sql_support_gate", fake_support_gate)
result = adapter_1c_server.metadata_write_preflight({
"base_id": "neft", "module_id": f"Config:{form_guid}.0",
"repository_lock": {"lock_session_id": "rlock-test", "repository_object": reference, "layer_id": "base"},
})
assert result["owner_resolution"]["repository_object"] == reference
assert captured["repository"]["repository_object"] == reference
assert captured["repository"]["lock_session_id"] == "rlock-test"
assert captured["support"]["support_object_guid"] == form_guid
def test_form_module_preflight_returns_concrete_saved_state_prepare_plan(monkeypatch) -> None:
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({
"resolved_object": "Document.тл_Планировщик.Form.ФормаДокументаНовая",
"form_guid": form_guid, "owner": {"kind": "Document", "name": "тл_Планировщик"},
}, None))
monkeypatch.setattr(adapter_1c_server, "metadata_write_plan", lambda payload: {
"allowed": False, "status": "needs_origin", "path_resolution": {}, "target": {},
"route": {}, "required_guards": [], "problems": [{"code": "origin_lookup_required"}],
})
monkeypatch.setattr(adapter_1c_server, "metadata_write_preflight_saved_target", lambda payload, plan: {})
monkeypatch.setattr(adapter_1c_server, "metadata_write_resolve_module_target", lambda payload, target, mode: ({
"error": "saved_state_prepare_required",
"prepared_module_ref": f"ConfigSave:{form_guid}.0",
"next_resolution": {
"method": "metadata.saved_state.prepare",
"params": {
"base_id": "neft",
"layer": "base_saved_state",
"ref": "Document.тл_Планировщик",
"mode": "plan",
},
},
"prepare_plan": {"status": "plan_ready"},
}, None))
monkeypatch.setattr(repository_control, "write_gate", lambda payload: {"allowed": True, "status": "ready"})
monkeypatch.setattr(adapter_1c_server, "live_sql_support_gate", lambda payload: {"allowed": True, "status": "ready"})
result = adapter_1c_server.metadata_write_preflight({"base_id": "neft", "module_id": f"Config:{form_guid}.0"})
assert result["status"] == "needs_prepare"
assert result["saved_state"]["prepare_plan"]["status"] == "plan_ready"
assert result["route"]["auto_resolves_saved_state"] is True
assert result["route"]["next_resolution"]["method"] == "metadata.saved_state.prepare"
assert result["route"]["next_resolution"]["params"]["ref"] == "Document.тл_Планировщик"
assert "payload" not in result["route"]["next_resolution"]
def test_form_module_context_selects_resolved_extension_layer(monkeypatch) -> None:
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
extension_guid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({
"resolved_object": "Document.Заказ.Form.Основная", "form_guid": form_guid,
"owner": {"kind": "Document", "name": "Заказ"}, "extension_guid": extension_guid,
}, None))
context = adapter_1c_server.resolve_write_gate_context({"base_id": "base", "module_id": f"Config:{form_guid}.0"})
assert context["extension_guid"] == extension_guid
assert repository_control.development_layer_id(context) == f"extension:{extension_guid}"
assert context["target"]["origin"]["extension"]["guid"] == extension_guid
def test_code_write_carries_form_module_gate_context_into_metadata_write(monkeypatch) -> None:
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая"
monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", lambda **kwargs: ({
"resolved_object": reference, "form_guid": form_guid,
"owner": {"kind": "Document", "name": "тл_Планировщик"},
}, None))
seen: dict[str, object] = {}
monkeypatch.setattr(adapter_1c_server, "metadata_write", lambda payload: (seen.update(payload) or {"status": "blocked"}))
result = adapter_1c_server.code_write({"base_id": "neft", "module_id": f"Config:{form_guid}.0", "routine_text": "Процедура Тест()\nКонецПроцедуры"})
assert result["owner_resolution"]["repository_object"] == reference
assert seen["repository_object"] == reference
assert seen["support_object_guid"] == form_guid
def test_saved_state_prepare_keeps_form_gate_context(monkeypatch) -> None:
form_guid = "f5f217c8-ac69-4e23-966b-d8ec0c665638"
reference = "Document.тл_Планировщик.Form.ФормаДокументаНовая"
captured: dict[str, object] = {}
monkeypatch.setattr(adapter_1c_server, "metadata_saved_state_prepare", lambda payload: (
captured.update(payload) or {"status": "verified"}
))
resolved, error = adapter_1c_server.metadata_write_resolve_module_target({
"base_id": "neft", "module_id": f"Config:{form_guid}.0",
"allow_sql_saved_state_apply": True,
"lock_session_id": "rlock-test",
"repository_object": reference,
"support_object_guid": form_guid,
}, {}, "apply")
assert error is not None
assert captured["lock_session_id"] == "rlock-test"
assert captured["repository_object"] == reference
assert captured["support_object_guid"] == form_guid
assert resolved["module_ref"] == f"ConfigSave:{form_guid}.0"
def test_non_form_module_id_does_not_receive_form_write_gate_context(monkeypatch) -> None:
called = False
def unexpected(**kwargs):
nonlocal called
called = True
return None, {"status": "not_found"}
monkeypatch.setattr(adapter_1c_server, "repository_resolve_form_guid_sql", unexpected)
context = adapter_1c_server.resolve_write_gate_context({"base_id": "neft", "module_id": "Config:not-a-guid.0"})
assert "owner_resolution" not in context
assert called is False
def test_repository_layers_audit_reports_all_discovered_layers(monkeypatch, tmp_path: Path) -> None:
configured_layers(monkeypatch, tmp_path)
monkeypatch.setattr(adapter_1c_server, "list_extensions", lambda payload: {"status": "ok", "extensions": [{"name": "Тест", "guid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}]})
monkeypatch.setattr(adapter_1c_server, "metadata_support_decode", lambda payload: {"status": "ok", "source": {"kind": "live_sql", "support_payload_present": False}, "suppliers": [{"general_mode": "editable"}], "diagnostics": {"source_discovery": "test"}, "object_support": {"status": "not_supported"}})
result = adapter_1c_server.call_method("repository.layers.audit", {"base_id": "base"})
assert result["status"] == "ok"
assert [layer["layer_id"] for layer in result["layers"]] == ["base", "extension:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"]
assert result["layers"][1]["safe_next_write_action"] == "request_and_confirm_repository_capture"
assert result["layers"][0]["support"]["general_modes"] == ["editable"]
def test_direct_apply_method_is_repository_gated(monkeypatch, tmp_path: Path) -> None:
configured_base(monkeypatch, tmp_path)
result = adapter_1c_server.metadata_module_write_apply({"base_id": "base", "execution_mode": "apply"})
@@ -230,6 +689,5 @@ def test_sql_only_mode_never_calls_external_repository_runner(monkeypatch, tmp_p
monkeypatch.setattr(repository_control, "_run_designer", forbidden)
result = repository_control.lock({"base_id": "base", "object": "Справочник.Товары", "allow_repository_lock": True})
assert result["status"] == "blocked"
assert result["execution"]["status"] == "external_1c_disabled"
assert result["status"] == "manual_action_required"
assert called is False
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "plugins" / "1c"))
sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector"))
import adapter_1c_server # noqa: E402
class FakeCursor:
def __init__(self) -> None:
self.rows = []
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def execute(self, query, _params=None) -> None:
if "FROM dbo.[Params]" in query:
self.rows = [
{"FileName": "a.ui", "PartNo": 0, "BinaryData": b"one"},
{"FileName": "a.ui", "PartNo": 1, "BinaryData": b"two"},
{"FileName": "b.si", "PartNo": 0, "BinaryData": b"three"},
]
else:
self.rows = []
def __iter__(self):
return iter(self.rows)
def fetchall(self):
return list(self.rows)
class FakeConnection:
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def cursor(self, as_dict=False):
assert as_dict is True
return FakeCursor()
def test_repository_sql_state_snapshot_hashes_parts_without_payload(monkeypatch) -> None:
monkeypatch.setattr(
adapter_1c_server,
"connect_live_sql",
lambda base_id, method, timeout_seconds=120: (FakeConnection(), {"database": "db"}, None),
)
result = adapter_1c_server.repository_sql_state_snapshot(
{"base_id": "base", "tables": ["Params"], "include_extensions": False, "diagnostic": True}
)
assert result["status"] == "ok"
assert result["counts"] == {"records": 2, "extensions": 0}
first = result["records"][0]
assert first["file_name"] == "a.ui"
assert first["part_count"] == 2
assert first["bytes"] == 6
assert "payload" not in first
assert result["diagnostics"]["payload_returned"] is False
token_diff = adapter_1c_server.repository_sql_state_diff(
{
"before_token": result["comparison_token"],
"after_token": result["comparison_token"],
"diagnostic": True,
}
)
assert token_diff["status"] == "ok"
assert token_diff["changed"] is False
def test_repository_sql_state_diff_reports_changed_part() -> None:
before = {
"schema": "onec_repository_sql_state_snapshot.v1",
"base_id": "base",
"snapshot_id": "before",
"records": [
{
"table": "Params",
"file_name": "a.ui",
"bytes": 3,
"sha1": "old",
"part_count": 1,
"parts": [{"part_no": 0, "bytes": 3, "sha1": "old"}],
}
],
"extensions": [],
}
after = {
**before,
"snapshot_id": "after",
"records": [
{
"table": "Params",
"file_name": "a.ui",
"bytes": 4,
"sha1": "new",
"part_count": 1,
"parts": [{"part_no": 0, "bytes": 4, "sha1": "new"}],
}
],
}
result = adapter_1c_server.repository_sql_state_diff(
{"before": before, "after": after, "diagnostic": True}
)
assert result["status"] == "ok"
assert result["changed"] is True
assert result["counts"] == {"added": 0, "removed": 0, "modified": 1}
assert result["modified"][0]["key"] == "storage:Params:a.ui"
assert result["modified"][0]["changed_parts"] == ["0"]
+71
View File
@@ -0,0 +1,71 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "plugins" / "1c"))
from parser.scheduled_job import decode_schedule, schedule_write_edits # noqa: E402
def atom(value: Any) -> dict[str, Any]:
return {"type": "atom", "value": str(value)}
def schedule_tree() -> dict[str, Any]:
return {
"type": "list",
"items": [
atom("00010101000000"),
atom("00010101000000"),
atom("00010101090000"),
atom("00010101180000"),
atom("00010101000000"),
atom(0),
atom(0),
atom(0),
atom(2),
atom(1),
atom(5),
atom(0),
atom(0),
atom(1),
atom(1),
atom(1),
atom(1),
],
}
def test_decode_schedule_is_adapter_independent() -> None:
result = decode_schedule(schedule_tree())
assert result["status"] == "ok"
assert result["begin_time"] == "09:00:00"
assert result["end_time"] == "18:00:00"
assert result["week_days"] == [1, 5]
assert result["months"] == [1]
def test_schedule_write_edits_rebuilds_resized_collections() -> None:
result = schedule_write_edits(
schedule_tree(),
{"week_days": [1, 2, 3, 4, 5], "months": [1, 6, 12]},
)
assert result["status"] == "ok"
assert result["counts"]["resized_collections"] == 2
rebuilt = decode_schedule(result["edits"][0]["replace_root"])
assert rebuilt["week_days"] == [1, 2, 3, 4, 5]
assert rebuilt["months"] == [1, 6, 12]
def test_schedule_write_edits_rejects_unknown_named_field() -> None:
result = schedule_write_edits(schedule_tree(), {"sql_path": "1.2.3"})
assert result["status"] == "invalid_argument"
assert result["error"] == "unsupported_schedule_fields"
assert result["diagnostics"]["fields"] == ["sql_path"]
+240
View File
@@ -0,0 +1,240 @@
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "plugins" / "1c"))
sys.path.insert(0, str(ROOT / "plugins" / "1c" / "connector"))
import adapter_1c_server # noqa: E402
from parser.support_rules import parse_parent_configurations_bytes
GUID = "8cec71dd-5046-4b16-ab13-cca39c826ca2"
CONFIG_GUID = "8cbe2d2d-295e-4b77-813b-63588e84f87a"
OTHER_GUID = "1fbe6b79-1ea3-43f9-bf72-f6cd4da9743b"
def payload(general_mode: int = 0, rule: int = 1) -> bytes:
text = (
f'{{6,0,1,{CONFIG_GUID},{general_mode},{OTHER_GUID},"1.2.3",'
f'"Поставщик","Конфигурация",1,{rule},0,{GUID},{GUID},0,0}}'
)
return text.encode("utf-8-sig")
def test_decodes_editable_support_rule() -> None:
result = parse_parent_configurations_bytes(payload())
supplier = result["suppliers"][0]
assert result["supplier_count"] == 1
assert supplier.general_mode == "editable"
assert supplier.name == "Конфигурация"
assert supplier.rules[0].object_guid == GUID
assert supplier.rules[0].rule == "editable_support_preserved"
def test_locked_general_mode_overrides_object_rule() -> None:
result = parse_parent_configurations_bytes(payload(general_mode=1, rule=2))
supplier = result["suppliers"][0]
assert supplier.general_mode == "locked"
assert supplier.rules[0].rule_code == 0
assert supplier.rules[0].rule == "not_editable"
@pytest.mark.parametrize(
("data", "message"),
[
(b'{5,0,0}', "expected format marker 6"),
(b'{6,0,1,not-a-guid,0,x,v,p,n,0,0,0}', "configuration GUID is invalid"),
(payload(rule=9), "unsupported object rule code 9"),
],
)
def test_rejects_invalid_support_payload(data: bytes, message: str) -> None:
with pytest.raises(ValueError, match=message):
parse_parent_configurations_bytes(data)
def test_sql_method_decodes_only_explicit_source_and_filters_object(monkeypatch) -> None:
monkeypatch.setattr(
adapter_1c_server,
"read_storage_file_bytes",
lambda base_id, table, file_name, timeout_seconds=30: (payload(), {"database": "db"}, None),
)
result = adapter_1c_server.metadata_support_decode(
{"base_id": "base", "table": "Params", "file_name": "support.si", "object_guid": GUID}
)
assert result["status"] == "ok"
assert result["source"] == {"kind": "live_sql", "database": "db", "table": "Params", "file_name": "support.si"}
assert result["matches"][0]["rule"] == "editable_support_preserved"
assert result["object_support"]["status"] == "editable_support_preserved"
assert result["object_support"]["edit_allowed_by_support"] is True
def test_sql_method_resolves_public_1c_name_before_filtering_support(monkeypatch) -> None:
captured: dict[str, str] = {}
def get_object(kind, name, **kwargs):
captured["kind"] = kind
captured["name"] = name
return {
"status": "ok",
"object": {
"kind": "CommonModule",
"name": "ОбменДанными",
"guid": GUID,
"ref": "CommonModule.ОбменДанными",
},
}
monkeypatch.setattr(adapter_1c_server, "get_object", get_object)
monkeypatch.setattr(
adapter_1c_server,
"read_storage_file_bytes",
lambda base_id, table, file_name, timeout_seconds=30: (payload(), {"database": "db"}, None),
)
result = adapter_1c_server.call_method(
"metadata.support.decode",
{
"base_id": "base",
"ref": "ОбщиеМодули.ОбменДанными",
"table": "Params",
"file_name": "support.si",
},
)
assert result["status"] == "ok"
assert captured == {"kind": "CommonModule", "name": "ОбменДанными"}
assert result["query"]["object_guid"] == GUID
assert result["query"]["object"]["ref"] == "CommonModule.ОбменДанными"
assert result["object_support"]["status"] == "editable_support_preserved"
def test_support_decode_help_publishes_optional_name_selector() -> None:
methods = {
row["name"]: row
for row in adapter_1c_server.call_method("help.methods", {})["methods"]
}
method = methods["metadata.support.decode"]
assert method["selector_capabilities"]["selector_purpose"] == "optional_object_support_filter"
assert method["selector_capabilities"]["accepts_ordinal"] is False
assert method["input_schema"]["properties"]["object_name"]["type"] == "string"
def test_sql_method_treats_absent_guid_as_not_supported_after_complete_decode(monkeypatch) -> None:
monkeypatch.setattr(
adapter_1c_server,
"read_storage_file_bytes",
lambda base_id, table, file_name, timeout_seconds=30: (payload(), {"database": "db"}, None),
)
result = adapter_1c_server.metadata_support_decode(
{"base_id": "base", "table": "Config", "file_name": "support.bin", "object_guid": OTHER_GUID}
)
assert result["status"] == "ok"
assert result["matches"] == []
assert result["object_support"] == {
"object_guid": OTHER_GUID,
"status": "not_supported",
"edit_allowed_by_support": True,
"evidence": "absent_from_complete_parent_configurations",
}
assert result["diagnostics"]["repository_lock_evaluated"] is False
def test_live_sql_support_gate_resolves_public_object_name(monkeypatch) -> None:
monkeypatch.setattr(
adapter_1c_server.repository_control,
"support_gate",
lambda request: {"required": True, "allowed": False, "status": "blocked_support_unknown"},
)
monkeypatch.setattr(
adapter_1c_server,
"get_object",
lambda kind, name, **kwargs: {"status": "ok", "object": {"guid": GUID}},
)
monkeypatch.setattr(
adapter_1c_server,
"metadata_support_decode",
lambda request: {
"status": "ok",
"object_support": {
"status": "not_supported",
"edit_allowed_by_support": True,
"evidence": "absent_from_complete_parent_configurations",
},
"diagnostics": {"source_discovery": "base_root_descriptor"},
},
)
gate = adapter_1c_server.live_sql_support_gate(
{"base_id": "base", "layer_id": "base", "ref": "ОбщийМодуль.Модуль"}
)
assert gate["allowed"] is True
assert gate["required"] is False
assert gate["mode"] == "live_sql"
assert gate["object_guid"] == GUID
def test_sql_method_discovers_base_source_and_rejects_invalid_payload(monkeypatch) -> None:
monkeypatch.setattr(
adapter_1c_server,
"discover_base_support_source",
lambda base_id, timeout_seconds=30: (
{
"kind": "live_sql",
"database": "db",
"table": "Config",
"file_name": f"{CONFIG_GUID}.4",
"root_file": OTHER_GUID,
"configuration_id": CONFIG_GUID,
"discovery": "base_root_descriptor",
},
None,
),
)
monkeypatch.setattr(
adapter_1c_server,
"read_storage_file_bytes",
lambda base_id, table, file_name, timeout_seconds=30: (payload(), {"database": "db"}, None),
)
discovered = adapter_1c_server.metadata_support_decode({"base_id": "base", "object_guid": GUID})
assert discovered["status"] == "ok"
assert discovered["source"]["file_name"] == f"{CONFIG_GUID}.4"
assert discovered["diagnostics"]["source_discovery"] == "base_root_descriptor"
monkeypatch.setattr(
adapter_1c_server,
"discover_extension_support_source",
lambda base_id, extension_guid, timeout_seconds=30: (
{
"kind": "live_sql",
"table": "ConfigCAS",
"configuration_id": OTHER_GUID,
"support_payload_present": False,
"discovery": "extension_cas_manifest",
},
None,
),
)
extension = adapter_1c_server.metadata_support_decode(
{"base_id": "base", "layer_id": f"extension:{CONFIG_GUID}", "object_guid": GUID}
)
assert extension["status"] == "ok"
assert extension["object_support"]["status"] == "not_supported"
assert extension["object_support"]["edit_allowed_by_support"] is True
assert extension["diagnostics"]["repository_lock_evaluated"] is False
monkeypatch.setattr(
adapter_1c_server,
"read_storage_file_bytes",
lambda base_id, table, file_name, timeout_seconds=30: (b"{0}", {"database": "db"}, None),
)
invalid = adapter_1c_server.metadata_support_decode(
{"base_id": "base", "table": "Params", "file_name": "not-support.si"}
)
assert invalid["status"] == "invalid_format"
assert invalid["diagnostics"]["permission_inference"] == "forbidden"
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import scripts.smoke_1c_write_plan_safety as smoke # noqa: E402
def test_repository_gate_falls_back_to_non_mutating_path_route_probe(monkeypatch) -> None:
calls: list[tuple[str, dict[str, Any]]] = []
def fake_rpc_call(
_endpoint_url: str,
method: str,
payload: dict[str, Any],
_timeout: float,
*,
transport: str,
session_id: str | None,
) -> dict[str, Any]:
calls.append((method, payload))
assert transport == "rest"
assert session_id is None
if method == "metadata.write":
if payload.get("mode") == "apply":
return {
"schema": "onec_repository_write_gate.v1",
"status": "blocked",
"error": "needs_repository_lock",
}
return {
"schema": "onec_metadata_write.v1",
"status": "blocked",
"error": "write_plan_required",
"routed_method": "metadata.write.plan",
"target_kind": "form",
}
intent = payload.get("intent") or {}
if intent.get("current_text") is not None:
return {
"status": "blocked",
"allowed": False,
"problems": [{"code": "control_fragment_drift"}],
}
if intent.get("control_fragment"):
return {
"status": "planned",
"allowed": True,
"route": {"apply_method": "metadata.module.write_apply"},
"target": {"target_kind": "module"},
}
return {
"status": "blocked",
"allowed": False,
"problems": [{"code": "missing_control_fragment"}],
}
monkeypatch.setattr(smoke, "rpc_call", fake_rpc_call)
result = smoke.run_smoke("http://adapter", "upo_test", 30, transport="rest")
assert result["status"] == "ok"
assert result["failures"] == []
assert result["checks"]["blocked_effective_form_path"]["apply_gate"] == {
"status": "blocked",
"error": "needs_repository_lock",
}
metadata_write_modes = [payload["mode"] for method, payload in calls if method == "metadata.write"]
assert metadata_write_modes == ["apply", "plan"]
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import scripts.smoke_1c_write_preflight as smoke # noqa: E402
def test_repository_lock_is_a_valid_preflight_classification() -> None:
assert smoke.classified_preflight_status("needs_repository_lock") is True
assert smoke.classified_preflight_status("unexpected") is False
def test_saved_module_target_prefers_bounded_stream(monkeypatch) -> None:
whole_form = {
"kind": "module",
"module_ref": "ConfigCASSave:extension__form.0",
"file_name": "extension__form.0",
"module_path": "2",
}
bounded_stream = {
"kind": "module",
"module_ref": "ConfigSave:module.3#stream:4",
"file_name": "module.3",
"stream_index": 4,
}
def fake_rpc_call(
_endpoint_url: str,
method: str,
_payload: dict[str, Any],
_timeout: float,
*,
transport: str,
session_id: str | None,
) -> dict[str, Any]:
assert method == "metadata.saved_state.modules.search"
assert transport == "rest"
assert session_id is None
return {
"status": "ok",
"modules": [
{"streams": [{"write_plan_target": whole_form}]},
{"streams": [{"write_plan_target": bounded_stream}]},
],
}
monkeypatch.setattr(smoke, "rpc_call", fake_rpc_call)
result = smoke.first_saved_module_target(
"http://adapter",
"upo_test",
30,
transport="rest",
session_id=None,
)
assert result == bounded_stream