Complete name-first 1C adapter saved-state support
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) });
|
||||
|
||||
@@ -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:<GUID>. 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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,6 +12,17 @@ from urllib.error import HTTPError, URLError
|
||||
DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011"
|
||||
DEFAULT_MCP_URL = "http://docker.cin.su:8021"
|
||||
EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1"
|
||||
PREFLIGHT_CLASSIFICATION_STATUSES = {
|
||||
"ready",
|
||||
"needs_prepare",
|
||||
"needs_resolution",
|
||||
"needs_repository_lock",
|
||||
"blocked",
|
||||
"blocked_by_support",
|
||||
"blocked_by_support_live_sql",
|
||||
"blocked_by_support_rule",
|
||||
"blocked_support_unknown",
|
||||
}
|
||||
|
||||
|
||||
def post_json(url: str, payload: dict[str, Any], *, timeout: float, headers: dict[str, str] | None = None) -> tuple[dict[str, str], dict[str, Any]]:
|
||||
@@ -106,6 +117,10 @@ def require(condition: bool, message: str, failures: list[str]) -> None:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def classified_preflight_status(value: Any) -> bool:
|
||||
return str(value or "") in PREFLIGHT_CLASSIFICATION_STATUSES
|
||||
|
||||
|
||||
def method_names(help_result: dict[str, Any]) -> set[str]:
|
||||
return {str(item.get("name") or "") for item in help_result.get("methods") or [] if isinstance(item, dict)}
|
||||
|
||||
@@ -120,13 +135,24 @@ def first_saved_module_target(endpoint_url: str, base_id: str, timeout: float, *
|
||||
session_id=session_id,
|
||||
)
|
||||
modules = result.get("modules") if isinstance(result.get("modules"), list) else []
|
||||
targets: list[dict[str, Any]] = []
|
||||
for module in modules:
|
||||
if not isinstance(module, dict):
|
||||
continue
|
||||
for stream in module.get("streams") or []:
|
||||
if isinstance(stream, dict) and isinstance(stream.get("write_plan_target"), dict):
|
||||
return stream["write_plan_target"]
|
||||
return None
|
||||
targets.append(stream["write_plan_target"])
|
||||
# A concrete stream target can be freshness-checked without decoding and
|
||||
# structurally diffing a whole saved form descriptor. Prefer it so this
|
||||
# deployment smoke remains bounded on large ConfigCASSave forms.
|
||||
return next(
|
||||
(
|
||||
target
|
||||
for target in targets
|
||||
if target.get("stream_index") is not None or "#stream:" in str(target.get("module_ref") or "")
|
||||
),
|
||||
targets[0] if targets else None,
|
||||
)
|
||||
|
||||
|
||||
def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str) -> dict[str, Any]:
|
||||
@@ -173,7 +199,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
|
||||
"plan_allowed": (blocked.get("plan") or {}).get("allowed") if isinstance(blocked.get("plan"), dict) else None,
|
||||
}
|
||||
require(blocked.get("schema") == "onec_metadata_write_preflight.v1", "preflight must return expected schema", failures)
|
||||
require(blocked.get("status") in {"blocked", "needs_resolution"}, "effective path preflight must be blocked or need resolution", failures)
|
||||
require(classified_preflight_status(blocked.get("status")), "effective path preflight must classify readiness or a safety gate", failures)
|
||||
require(blocked.get("allowed") is False, "effective path preflight must not be allowed", failures)
|
||||
|
||||
target = first_saved_module_target(endpoint_url, base_id, timeout, transport=transport, session_id=session_id)
|
||||
@@ -201,7 +227,7 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
|
||||
"writer": (concrete.get("route") or {}).get("writer") if isinstance(concrete.get("route"), dict) else None,
|
||||
}
|
||||
require(concrete.get("schema") == "onec_metadata_write_preflight.v1", "concrete preflight must return expected schema", failures)
|
||||
require(concrete.get("status") in {"ready", "needs_prepare", "blocked"}, "concrete preflight must classify readiness", failures)
|
||||
require(classified_preflight_status(concrete.get("status")), "concrete preflight must classify readiness or a safety gate", failures)
|
||||
if concrete.get("status") == "ready":
|
||||
require(freshness.get("status") == "live_sql_verified", "ready concrete preflight must be live SQL verified", failures)
|
||||
else:
|
||||
@@ -230,7 +256,18 @@ def main() -> int:
|
||||
args = parser.parse_args()
|
||||
|
||||
endpoint_url = args.mcp_url if args.transport == "mcp" else args.base_url
|
||||
report = run_smoke(endpoint_url, args.base_id, args.timeout, transport=args.transport)
|
||||
try:
|
||||
report = run_smoke(endpoint_url, args.base_id, args.timeout, transport=args.transport)
|
||||
except (TimeoutError, URLError, OSError) as exc:
|
||||
report = {
|
||||
"schema": "onec_write_preflight_smoke.v1",
|
||||
"status": "failed",
|
||||
"endpoint_url": endpoint_url,
|
||||
"transport": args.transport,
|
||||
"base_id": args.base_id,
|
||||
"checks": {},
|
||||
"failures": [f"transport timeout/error: {type(exc).__name__}: {str(exc)[:300]}"],
|
||||
}
|
||||
if args.report:
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+3212
-34
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user