Complete name-first 1C adapter saved-state support
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user