Complete name-first 1C adapter saved-state support

This commit is contained in:
2026-07-26 16:39:53 +03:00
parent b8c62fa8fa
commit aed134d817
44 changed files with 15436 additions and 697 deletions
+10
View File
@@ -28,8 +28,18 @@ ONEC_INFOBASE_USER_ADMIN_TOKEN_UPO_TEST=
ONEC_INFOBASE_USER_ADMIN_ALLOW_UNAUTHENTICATED=false
ONEC_ADAPTER_CACHE_DB=/data/adapter-cache.sqlite
# Adapter-owned runtime state (jobs, progress, restart diagnostics). This is
# local SQLite and must not point to a 1C SQL database. Defaults to CACHE_DB.
ONEC_ADAPTER_STATE_DB=/data/adapter-cache.sqlite
# Legacy JSON job store is read once for migration only.
ONEC_ADAPTER_JOB_STORE=/data/adapter-jobs.json
ONEC_ADAPTER_BACKUP_DIR=/data/adapter-apply-backups
ONEC_ADAPTER_WRITE_LEARNING_DIR=/data/adapter-write-learning
ONEC_ADAPTER_JOB_TIMEOUT_SECONDS=240
ONEC_ADAPTER_FULL_TIMEOUT_SECONDS=600
ONEC_ADAPTER_SECTION_TIMEOUT_SECONDS=180
# Run non-stateful background calls in killable child processes.
ONEC_ADAPTER_JOB_PROCESS_ISOLATION=true
# Optional POSIX child-process limits; 0 keeps the platform/container limit.
ONEC_ADAPTER_JOB_MEMORY_LIMIT_MB=0
ONEC_ADAPTER_JOB_CPU_LIMIT_SECONDS=0
+39 -1
View File
@@ -223,6 +223,7 @@ Current live methods:
- `metadata.objects.list`
- `metadata.object.get`
- `metadata.object.properties`
- `metadata.object.property.write`
- `metadata.object.decode`
- `metadata.object.parts`
- `metadata.object.modules`
@@ -260,6 +261,13 @@ qualified metadata names such as `Обработка.<Name>` or `Document.<Name>
Client, MCP, and agent code must not add conditions for concrete object names;
the adapter owns generic selector normalization.
Saved-state client calls use the same name-first selectors together with
`layer=base_saved_state|extension_saved_state`. SQL tables, file names, GUID
owners, and module handles are diagnostic continuations exposed only with
`include_storage=true`. Every public RPC follow-up is shaped as
`{"method": "...", "params": {...}}`; `payload` is not the arguments field of
`next_call` or `next_resolution`.
Layer write policy:
- `Config` and `ConfigCAS` are **active-applied** and must be treated as read-only in adapter workflows.
@@ -293,6 +301,26 @@ metadata kind. It selects a kind-specific SQL decoder for `Configuration`,
semantic profile. XML exports are analysis evidence only and are never a
runtime source for this method or any other adapter method.
`metadata.object.property.write` is the name-first saved-state writer for the
standard identity properties `synonym` and `comment`. Pass a public object
`ref` or `kind` + `name`. For an existing attribute, tabular section,
dimension, or resource, also pass `member_ref` or `member_kind` +
`member_name`; the adapter resolves the exact parent/member GUIDs and
serialized tree path internally. The method supports `plan`, `apply`,
`apply_and_verify`, and `apply_and_rollback`, requires explicit saved-state
gates, and never writes active `Config`/`ConfigCAS`. Renaming an object and
member, adding/removing collection items, and adding a new synonym locale
remain intentionally disabled.
`metadata.object.member.add` adds one new object requisite or tabular-section
column (`Attribute`) by cloning an existing attribute in the same collection.
The caller passes only
`template_member_ref`, `new_member_name`, and optionally synonym/comment; the
adapter generates the GUID, preserves the template's type/settings, appends to
the exact declared collection, and verifies the new identity after apply.
Arbitrary type construction and deletion are not supported by this first
structural route.
Managed form bodies in base `Config` are resolved from the public form GUID to
the sibling `<guid>.0` SQL payload. Command-bar buttons expose public command
names when their SQL binding points to a common command or a recognized
@@ -468,7 +496,17 @@ capabilities).
`metadata.adapter.audit` reports recognized metadata kinds, public kind counts,
missing supported kinds when `include_missing=true`, and unmapped DBNames roles
when `include_unmapped=true`.
when `include_unmapped=true`. Missing entries are marked
`presence_status=supported_absent_in_selected_base`; this is a statement about
the selected infobase, not a claim that the adapter lacks that kind.
Use `python scripts/check_1c_metadata_kind_fixtures.py --live` from the
repository root to check the reproducible rare-kind fixtures. The checker does
not write SQL or create metadata. The fixture manifest pins the exact Designer
version and external reference commit. The companion
`scripts/export_1c_extension_sources.ps1` performs a read-only `test2` source
export with operating-system integrated authentication and exposes no
infobase-user or credential arguments.
`codec.decode` and `codec.encode` are low-level lossless helpers. A no-op encode
from a live source keeps the original bytes exactly; modified text/tree payloads
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -74,12 +74,7 @@ function openEditor(base = null) {
$("baseId").value = base?.base_id || ""; $("server").value = base?.server || "";
$("database").value = base?.database || ""; $("user").value = base?.user || "";
$("password").value = ""; $("passwordEnv").value = base?.password_env || "";
$("repositoryEnabled").checked = Boolean(base?.repository);
$("repositoryBackend").value = base?.repository?.backend || "direct";
$("repositoryLayer").value = base?.repository?.layer || "base";
$("repositoryLockMode").value = base?.repository?.lock_mode || "automatic";
$("repositoryBridgeId").value = base?.repository?.bridge_id || "";
$("repositoryUser").value = base?.repository?.repository_user || "";
$("developmentLayers").value = JSON.stringify(base?.development_layers || { base: { repository: { mode: "none", connection_state: "not_configured" }, support: { mode: "unknown" } } }, null, 2);
$("passwordHint").textContent = base?.has_password ? "Пароль уже задан. Оставьте пустым, чтобы сохранить текущий." : "Задайте пароль или переменную окружения.";
$("editor").showModal(); setTimeout(() => $("baseId").focus(), 30);
}
@@ -88,7 +83,10 @@ async function save(event) {
event.preventDefault();
if (!$("baseForm").reportValidity()) return;
const original = $("originalId").value;
const payload = { base_id: $("baseId").value.trim(), server: $("server").value.trim(), database: $("database").value.trim(), user: $("user").value.trim(), password: $("password").value, password_env: $("passwordEnv").value.trim(), repository: $("repositoryEnabled").checked ? { backend: $("repositoryBackend").value, layer: $("repositoryLayer").value, lock_mode: $("repositoryLockMode").value, bridge_id: $("repositoryBridgeId").value.trim(), repository_user: $("repositoryUser").value.trim() } : { enabled: false } };
let developmentLayers;
try { developmentLayers = JSON.parse($("developmentLayers").value || "{}"); }
catch (_) { notice("Слои разработки должны быть корректным JSON.", true); return; }
const payload = { base_id: $("baseId").value.trim(), server: $("server").value.trim(), database: $("database").value.trim(), user: $("user").value.trim(), password: $("password").value, password_env: $("passwordEnv").value.trim(), development_layers: developmentLayers, repository: { enabled: false } };
$("saveButton").disabled = true;
try {
await request(original ? `/admin/api/bases/${encodeURIComponent(original)}` : "/admin/api/bases", { method: original ? "PUT" : "POST", body: JSON.stringify(payload) });
+1 -6
View File
@@ -43,12 +43,7 @@
<label>SQL-логин<input id="user" required autocomplete="username" placeholder="onec_reader"></label>
<label class="wide">Пароль<input id="password" type="password" autocomplete="new-password" placeholder="Оставьте пустым, чтобы не менять"><small id="passwordHint">Пароль сохраняется в защищённом runtime-файле и никогда не отображается.</small></label>
<label class="wide">Или переменная окружения<input id="passwordEnv" placeholder="ONEC_SQL_PASSWORD_UPO_TEST"><small>Если заполнено, имеет приоритет над введённым паролем.</small></label>
<label class="wide"><input id="repositoryEnabled" type="checkbox"> Конфигурация подключена к хранилищу</label>
<label>Доступ к хранилищу<select id="repositoryBackend"><option value="direct">Прямой</option><option value="karman_bridge">Через Карман</option></select></label>
<label>Слой<select id="repositoryLayer"><option value="base">Основная конфигурация</option><option value="extension">Расширение</option></select></label>
<label>Захват объектов<select id="repositoryLockMode"><option value="manual">Вручную пользователем (SQL-only)</option><option value="automatic" disabled>Через внешнюю 1С (следующая версия)</option></select></label>
<label>ID моста (из настройки)<input id="repositoryBridgeId" placeholder="Необязательно"></label>
<label>Пользователь хранилища<input id="repositoryUser" autocomplete="off" placeholder="Например, adm"></label>
<label class="wide">Слои разработки (JSON)<textarea id="developmentLayers" rows="12" spellcheck="false" placeholder='{"base":{"repository":{"mode":"manual"},"support":{"mode":"unknown"}}}'></textarea><small>Основная конфигурация задаётся ключом base, каждое расширение — отдельным ключом extension:&lt;GUID&gt;. repository.mode: none/manual/automatic/unknown; support.mode: none/editable/locked/rules/unknown.</small></label>
</div>
<div class="dialog-actions"><button value="cancel" class="secondary">Отмена</button><button id="saveButton" value="default" class="primary">Сохранить</button></div>
</form>
+266 -6
View File
@@ -16,6 +16,164 @@ paths:
responses:
"200":
description: Connector health.
/methods:
get:
operationId: listAdapterMethods
summary: Runtime adapter method registry
description: Returns every supported RPC method, transport, description, selector capabilities, and input schema known by the running adapter.
parameters:
- name: method
in: query
required: false
schema:
type: string
description: Optional exact method name.
responses:
"200":
description: Runtime registry generated from the adapter method definitions.
content:
application/json:
schema:
$ref: "#/components/schemas/AdapterMethodsResponse"
/rpc:
post:
operationId: callAdapterMethod
summary: Universal adapter RPC
description: Calls any method advertised by GET /methods. The selected method's input_schema from /methods defines payload.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AdapterRpcRequest"
responses:
"200":
description: Method-specific adapter response. Application errors are returned as structured JSON statuses.
content:
application/json:
schema:
type: object
additionalProperties: true
/extensions:
get:
operationId: listExtensions
parameters:
- $ref: "#/components/parameters/BaseId"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/object/templates:
post:
operationId: listObjectTemplates
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/object/template-details:
post:
operationId: getObjectTemplateDetails
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/object/form-details:
post:
operationId: getObjectFormDetails
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/object/commands:
post:
operationId: listObjectCommands
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/object/special-details:
post:
operationId: getObjectSpecialDetails
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/module-owner-cache/prune:
post:
operationId: pruneModuleOwnerCache
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/code/search:
post:
operationId: searchCode
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/code/read:
post:
operationId: readCode
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/templates/bindings:
post:
operationId: getTemplateBindings
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/diagnostics/call-chain:
post:
operationId: diagnoseCallChain
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/access/users/search:
post:
operationId: searchAccessUsers
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/access/object/roles:
post:
operationId: getAccessObjectRoles
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/access/object/subjects:
post:
operationId: getAccessObjectSubjects
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/access/rls/discover:
post:
operationId: discoverAccessRls
requestBody:
$ref: "#/components/requestBodies/AdapterMethodPayload"
responses:
"200":
$ref: "#/components/responses/AdapterMethodResponse"
/metadata/kinds:
get:
operationId: getMetadataKinds
@@ -1734,7 +1892,11 @@ paths:
type: string
enum: [group, user_set, object, set, all]
default: all
description: BSP access key area to page through.
description: BSP access key area to page through; this is not a 1C metadata kind.
area:
type: string
enum: [group, user_set, object, set, all]
description: Alias of kind.
group:
type: string
description: Filter group access keys by normalized group id.
@@ -1744,9 +1906,21 @@ paths:
user_set:
type: string
description: Filter user-set access keys by normalized user set id.
object_ref:
type: string
description: Public 1C metadata object ref, for example Справочники.Номенклатура.
record_ref:
type: string
description: Concrete 1C application-data record reference.
object:
type: string
description: Filter object access keys by normalized object id.
description: Legacy raw BSP object filter; prefer object_ref plus record_ref.
object_id:
type: string
description: Legacy raw BSP record id; prefer record_ref.
object_sql_number:
type: integer
description: Legacy physical DBNames number; prefer object_ref.
access_set:
type: string
description: Filter access-set keys by normalized access set id.
@@ -1795,9 +1969,21 @@ paths:
properties:
base_id:
type: string
object_ref:
type: string
description: Public 1C metadata object ref, for example Справочники.Номенклатура.
record_ref:
type: string
description: Concrete 1C application-data record reference.
object:
type: string
description: Optional normalized object ref filter.
description: Legacy raw BSP object filter; prefer object_ref plus record_ref.
object_id:
type: string
description: Legacy raw BSP record id; prefer record_ref.
object_sql_number:
type: integer
description: Legacy physical DBNames number; prefer object_ref.
access_key:
type: string
description: Optional access key filter.
@@ -1836,15 +2022,21 @@ paths:
properties:
base_id:
type: string
object_ref:
type: string
description: Public 1C metadata object ref, for example Справочники.Номенклатура.
record_ref:
type: string
description: Concrete 1C application-data record reference.
object:
type: string
description: Normalized object ref from access_object_keys.object.
description: Legacy raw BSP object filter; prefer object_ref plus record_ref.
object_id:
type: string
description: Data record id from access_object_keys.object_id.
description: Legacy raw BSP record id; prefer record_ref.
object_sql_number:
type: integer
description: Optional SQL metadata number to narrow object key rows.
description: Legacy physical DBNames number; prefer object_ref.
access_key:
type: string
description: Access key id to explain directly.
@@ -2020,6 +2212,27 @@ paths:
"200":
description: Risk findings for role -> profile -> access group -> user audit chains.
components:
requestBodies:
AdapterMethodPayload:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
examples:
selectorByName:
value:
base_id: <base_id>
ref: Документы.<ИмяОбъекта>
responses:
AdapterMethodResponse:
description: Method-specific adapter response with a structured status.
content:
application/json:
schema:
type: object
additionalProperties: true
parameters:
BaseId:
name: base_id
@@ -2033,6 +2246,53 @@ components:
type: http
scheme: bearer
schemas:
AdapterRpcRequest:
type: object
required: [method]
additionalProperties: false
properties:
method:
type: string
minLength: 1
description: Exact method name advertised by GET /methods.
payload:
type: object
default: {}
additionalProperties: true
description: Method-specific input. Use the matching input_schema returned by GET /methods.
AdapterMethod:
type: object
required: [name, transport, description]
additionalProperties: true
properties:
name:
type: string
transport:
type: string
description:
type: string
selector_capabilities:
type: object
additionalProperties: true
input_schema:
type: object
additionalProperties: true
AdapterMethodsResponse:
type: object
required: [schema, contract_version, methods, count]
properties:
schema:
type: string
const: onec_adapter_methods.v1
contract_version:
type: string
methods:
type: array
items:
$ref: "#/components/schemas/AdapterMethod"
count:
type: integer
minimum: 0
MetadataKind:
type: string
enum:
+308 -42
View File
@@ -15,6 +15,14 @@ from typing import Any
METHOD_STATUS = "repository.status"
SQL_LOCK_EVIDENCE = {
"native_lock_state": "unknown",
"native_lock_verification": "not_available_via_infobase_sql",
"excluded_sources": {
"_ConfigChngR": "exchange_plan_change_registration",
"_ConfigChngR_ExtProps": "exchange_plan_changed_file_details",
},
}
METHOD_LOCK_PLAN = "repository.lock.plan"
METHOD_LOCK_REQUEST = "repository.lock.request"
METHOD_LOCK_REQUEST_STATUS = "repository.lock.request.status"
@@ -29,6 +37,9 @@ METHOD_COMMIT = "repository.commit"
METHODS = {METHOD_STATUS, METHOD_LOCK_PLAN, METHOD_LOCK_REQUEST, METHOD_LOCK_REQUEST_STATUS, METHOD_LOCK_REQUEST_CANCEL, METHOD_LOCK, METHOD_CONFIRM, METHOD_VERIFY, METHOD_CLOSE, METHOD_UNLOCK, METHOD_COMMIT_PLAN, METHOD_COMMIT}
SUPPORTED_BACKENDS = {"direct", "karman_bridge"}
SUPPORTED_LOCK_MODES = {"automatic", "manual"}
SUPPORTED_REPOSITORY_MODES = {"none", "manual", "automatic", "unknown"}
SUPPORTED_REPOSITORY_CONNECTION_STATES = {"not_configured", "configured", "unavailable", "unknown"}
SUPPORTED_SUPPORT_MODES = {"none", "editable", "locked", "rules", "unknown"}
_BASE_LOCKS: dict[str, threading.Lock] = {}
_BASE_LOCKS_GUARD = threading.Lock()
_STATE_LOCK = threading.RLock()
@@ -66,13 +77,60 @@ def _load_json_map(env_name: str, file_env_name: str) -> tuple[dict[str, Any] |
return value, None
def repository_config(base_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
def development_layer_id(payload: dict[str, Any] | None = None) -> str:
value = payload if isinstance(payload, dict) else {}
target = value.get("target") if isinstance(value.get("target"), dict) else {}
origin = target.get("origin") if isinstance(target.get("origin"), dict) else {}
origin_extension = origin.get("extension") if isinstance(origin.get("extension"), dict) else {}
extension_guid = str(value.get("extension_guid") or target.get("extension_guid") or origin_extension.get("guid") or "").strip().lower()
layer_id = str(value.get("layer_id") or target.get("layer_id") or "").strip().lower()
if extension_guid:
return f"extension:{extension_guid}"
if layer_id.startswith("extension:"):
return layer_id
return "base"
def _base_settings(base_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
values, error = _load_json_map("ONEC_SQL_BASES_JSON", "ONEC_SQL_BASES_JSON_FILE")
base_item = values.get(base_id) if values and isinstance(values.get(base_id), dict) else None
item = base_item.get("repository") if isinstance(base_item, dict) else None
if base_item is not None:
return base_item, None
return None, error or {"status": "not_configured", "message": f"No SQL configuration for base_id '{base_id}'."}
def development_layer_config(base_id: str, layer_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
base_item, base_error = _base_settings(base_id)
if base_error:
return None, base_error
layers = base_item.get("development_layers") if isinstance(base_item.get("development_layers"), dict) else None
if layers is not None:
item = layers.get(layer_id)
if not isinstance(item, dict):
return None, {"status": "layer_not_configured", "message": f"No development layer configuration for '{layer_id}' in base_id '{base_id}'."}
return dict(item), None
if layer_id == "base" and isinstance(base_item.get("repository"), dict):
return {"repository": dict(base_item["repository"]), "legacy": True}, None
return None, {"status": "layer_not_configured", "message": f"No development layer configuration for '{layer_id}' in base_id '{base_id}'."}
def repository_config(base_id: str, layer_id: str = "base") -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
base_item, _ = _base_settings(base_id)
layer, layer_error = development_layer_config(base_id, layer_id)
if layer_error is None:
item = layer.get("repository")
if item is None:
return {"mode": "unknown", "lock_mode": "manual", "layer_id": layer_id, "layer": layer_id}, None
else:
item = None
if isinstance(base_item, dict) and isinstance(base_item.get("development_layers"), dict):
return None, layer_error
# Legacy repository-only configuration remains readable for the base layer.
if item is None and layer_id == "base" and isinstance(base_item, dict):
item = base_item.get("repository")
if item is None:
values, repository_error = _load_json_map("ONEC_REPOSITORY_BASES_JSON", "ONEC_REPOSITORY_BASES_JSON_FILE")
if repository_error and error:
if repository_error and layer_error:
return None, repository_error
item = values.get(base_id) if values else None
if item is None:
@@ -80,9 +138,21 @@ def repository_config(base_id: str) -> tuple[dict[str, Any] | None, dict[str, An
if not isinstance(item, dict):
return None, {"status": "invalid_config", "message": f"Repository configuration for '{base_id}' must be an object."}
configured = dict(item)
configured["mode"] = str(configured.get("mode") or configured.get("lock_mode") or "automatic").strip().casefold()
if configured["mode"] not in SUPPORTED_REPOSITORY_MODES:
return None, {"status": "invalid_config", "message": "repository mode must be none, manual, automatic, or unknown."}
default_connection_state = "unknown" if configured["mode"] == "unknown" else ("not_configured" if configured["mode"] == "none" else "configured")
configured["connection_state"] = str(configured.get("connection_state") or configured.get("repository_connection") or configured.get("connection") or default_connection_state).strip().casefold()
if configured["connection_state"] not in SUPPORTED_REPOSITORY_CONNECTION_STATES:
return None, {"status": "invalid_config", "message": "repository connection_state must be not_configured, configured, unavailable, or unknown."}
if configured["mode"] == "unknown":
return {"mode": "unknown", "lock_mode": "manual", "connection_state": configured["connection_state"], "layer_id": layer_id, "layer": layer_id}, None
if configured["mode"] == "none":
return {"mode": "none", "lock_mode": "manual", "connection_state": configured["connection_state"], "layer_id": layer_id, "layer": layer_id}, None
configured["backend"] = str(configured.get("backend") or "direct").strip().casefold()
configured["layer"] = str(configured.get("layer") or "base").strip().casefold()
configured["lock_mode"] = str(configured.get("lock_mode") or "automatic").strip().casefold()
configured["layer_id"] = layer_id
configured["layer"] = layer_id
configured["lock_mode"] = configured["mode"]
if configured["backend"] not in SUPPORTED_BACKENDS:
return None, {"status": "invalid_config", "message": "repository backend must be direct or karman_bridge."}
if configured["lock_mode"] not in SUPPORTED_LOCK_MODES:
@@ -105,6 +175,25 @@ def repository_config(base_id: str) -> tuple[dict[str, Any] | None, dict[str, An
return configured, None
def manual_confirmation_required(config: dict[str, Any]) -> bool:
"""Whether SQL-only operation needs the user's exact Configurator capture confirmation."""
if config.get("mode") in {"none", "unknown"} or config.get("connection_state") == "not_configured":
return False
return config.get("connection_state") == "unavailable" or config.get("lock_mode") == "manual" or not external_1c_enabled()
def manual_confirmation_next_call(base_id: str, request_id: str) -> dict[str, Any]:
"""Machine-readable continuation; clients must not infer confirmation keys."""
return {
"method": METHOD_CONFIRM,
"params": {
"base_id": base_id,
"request_id": request_id,
"user_confirmed_locked": True,
},
}
def _secret(config: dict[str, Any], field: str) -> str:
env_name = str(config.get(f"{field}_env") or "").strip()
return os.environ.get(env_name, "") if env_name else ""
@@ -112,9 +201,12 @@ def _secret(config: dict[str, Any], field: str) -> str:
def _public_config(config: dict[str, Any]) -> dict[str, Any]:
return {
"mode": config.get("mode") or config.get("lock_mode"),
"layer_id": config.get("layer_id") or config.get("layer"),
"backend": config.get("backend"),
"layer": config.get("layer"),
"lock_mode": config.get("lock_mode"),
"connection_state": config.get("connection_state"),
"adapter_access_mode": "sql_only" if not external_1c_enabled() else "sql_and_external_1c",
"automatic_repository_operations_available": external_1c_enabled(),
"endpoint": config.get("endpoint"),
@@ -134,6 +226,24 @@ def _public_config(config: dict[str, Any]) -> dict[str, Any]:
}
def public_development_layers(base_id: str) -> dict[str, Any]:
base_item, error = _base_settings(base_id)
if error or not isinstance(base_item, dict):
return {}
layers = base_item.get("development_layers") if isinstance(base_item.get("development_layers"), dict) else {}
result: dict[str, Any] = {}
for layer_id, layer in layers.items():
if not isinstance(layer, dict):
continue
repository, repository_error = repository_config(base_id, str(layer_id))
support = layer.get("support") if isinstance(layer.get("support"), dict) else {"mode": "unknown"}
result[str(layer_id)] = {
"repository": _public_config(repository) if repository and not repository_error else {"status": "invalid", "problem": repository_error},
"support": {"mode": str(support.get("mode") or "unknown"), "rules": dict(support.get("rules") or {}) if isinstance(support.get("rules"), dict) else {}},
}
return result
def _infobase_args(config: dict[str, Any]) -> list[str]:
infobase = config["infobase"]
if infobase.get("file"):
@@ -311,24 +421,38 @@ def lock_plan(payload: dict[str, Any]) -> dict[str, Any]:
"schema": "onec_repository_lock_plan.v1",
"method": METHOD_LOCK_PLAN,
"status": "ready" if not warnings else "needs_confirmation",
"scope_status": "resolved",
"operation": operation,
"requested_objects": [str(x) for x in (payload.get("objects") or [payload.get("object") or payload.get("ref") or payload.get("path")])],
"lock_objects": objects,
"warnings": warnings,
"native_lock_state": "unknown",
"native_lock_verification": "not_available_via_infobase_sql" if not external_1c_enabled() else "not_checked",
"capture_required": None,
"sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [],
}
base_id = str(payload.get("base_id") or "").strip()
if base_id:
config, config_error = repository_config(base_id)
layer_id = development_layer_id(payload)
result["layer_id"] = layer_id
config, config_error = repository_config(base_id, layer_id)
if config_error:
result["repository_problem"] = config_error
elif config.get("lock_mode") == "manual":
elif config.get("mode") == "unknown" or config.get("connection_state") == "unknown":
result["workflow"] = "blocked"
result["status"] = "blocked_repository_connection_unknown"
result["next_method"] = None
elif config.get("mode") == "none" or config.get("connection_state") == "not_configured":
result["workflow"] = "not_required"
result["next_method"] = None
elif manual_confirmation_required(config):
result["workflow"] = "manual"
result["next_method"] = METHOD_CONFIRM
result["next_method"] = METHOD_LOCK_REQUEST if not external_1c_enabled() else METHOD_CONFIRM
result["user_action"] = {
"action": "lock_in_configurator",
"action": "confirm_lock_in_configurator",
"base_id": base_id,
"objects": objects,
"message": "Захватите перечисленные объекты в Конфигураторе, затем явно подтвердите тот же список через repository.lock.confirm.",
"message": "SQL информационной базы не показывает нативное состояние захвата. Убедитесь в Конфигураторе, что перечисленные объекты уже захвачены указанным пользователем хранилища, и явно подтвердите тот же список через repository.lock.confirm.",
}
else:
result["workflow"] = "automatic"
@@ -338,36 +462,57 @@ def lock_plan(payload: dict[str, Any]) -> dict[str, Any]:
def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
layer_id = development_layer_id(payload)
config, error = repository_config(base_id, layer_id)
if error:
return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, **error}
plan = lock_plan(payload)
if plan.get("workflow") == "not_required":
return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, "layer_id": layer_id, "status": "not_required", "plan": plan}
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
plan["status"] = "ready"
if plan.get("status") != "ready":
return {"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, "status": "blocked", "plan": plan}
configured_repository_user = str(config.get("repository_user") or "").strip()
requested_repository_user = str(payload.get("confirmed_repository_user") or payload.get("repository_user") or "").strip()
if requested_repository_user and configured_repository_user and requested_repository_user.casefold() != configured_repository_user.casefold():
return {
"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST,
"base_id": base_id, "layer_id": layer_id, "status": "blocked",
"error": "repository_user_mismatch", "expected_repository_user": configured_repository_user,
}
repository_user = requested_repository_user or configured_repository_user
request_id = "rreq-" + uuid.uuid4().hex
with _STATE_LOCK:
state = _read_state()
state.setdefault("requests", {})[request_id] = {
"base_id": base_id,
"layer": str(config.get("layer") or "base"),
"layer": layer_id,
"layer_id": layer_id,
"backend": config.get("backend"),
"operation": plan.get("operation"),
"objects": plan["lock_objects"],
"created_at": time.time(),
"status": "pending_user_lock",
"execution": "manual",
"repository_user": repository_user,
"sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [],
}
_audit(state, "lock_request_created", request_id=request_id, base_id=base_id, objects=plan["lock_objects"])
_write_state(state)
return {
"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST,
"base_id": base_id, "status": "pending_user_lock", "request_id": request_id,
"base_id": base_id, "layer_id": layer_id, "status": "pending_user_lock", "request_id": request_id,
"objects": plan["lock_objects"], "automatically_locked": False,
"user_action": "Захватите перечисленные объекты в Конфигураторе и подтвердите заявку через repository.lock.confirm.",
"sql_resolution": payload.get("sql_resolution") if isinstance(payload.get("sql_resolution"), list) else [],
"native_lock_state": "unknown",
"native_lock_verification": "not_available_via_infobase_sql",
"capture_required": None,
"repository_user": repository_user or None,
"repository_user_source": "layer_connection_setting" if configured_repository_user and not requested_repository_user else ("request" if requested_repository_user else "not_configured"),
"user_action": "Убедитесь в Конфигураторе, что перечисленные объекты захвачены вами под настроенным пользователем хранилища, и явно подтвердите захват через repository.lock.confirm.",
"next_method": METHOD_CONFIRM,
"next_call": manual_confirmation_next_call(base_id, request_id),
}
@@ -376,7 +521,11 @@ def lock_request_status(payload: dict[str, Any]) -> dict[str, Any]:
request = (_read_state().get("requests") or {}).get(request_id)
if not isinstance(request, dict):
return {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": "not_found", "request_id": request_id}
return {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": request.get("status"), "request_id": request_id, "request": request}
result = {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": request.get("status"), "request_id": request_id, "request": request}
if request.get("status") == "pending_user_lock":
result["next_method"] = METHOD_CONFIRM
result["next_call"] = manual_confirmation_next_call(str(request.get("base_id") or ""), request_id)
return result
def cancel_lock_request(payload: dict[str, Any]) -> dict[str, Any]:
@@ -449,25 +598,34 @@ def admin_state(base_id: str = "") -> dict[str, Any]:
def status(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
layer_id = development_layer_id(payload)
config, error = repository_config(base_id, layer_id)
if error:
return {"schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id, "connected": False, **error}
result: dict[str, Any] = {
"schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id,
"schema": "onec_repository_status.v1", "method": METHOD_STATUS, "base_id": base_id, "layer_id": layer_id,
"status": "configured", "connected": True, "available": None, "repository": _public_config(config),
"sql_lock_evidence": SQL_LOCK_EVIDENCE,
}
if config.get("mode") == "none" or config.get("connection_state") == "not_configured":
result.update({"status": "repository_not_connected", "connected": False, "available": None})
return result
if config.get("mode") == "unknown" or config.get("connection_state") == "unknown":
result.update({"status": "repository_connection_unknown", "connected": False, "available": None})
return result
if manual_confirmation_required(config):
result.update({"status": "manual_confirmation_required", "connected": False, "available": False if config.get("connection_state") == "unavailable" else None})
if bool(payload.get("probe")):
result["probe"] = {"status": "not_applicable", "message": "Manual SQL-only workflow does not establish a native repository connection."}
return result
if not bool(payload.get("probe")):
return result
if not external_1c_enabled():
result["status"] = "sql_only"
result["connected"] = False
result["available"] = None
result["probe"] = {"status": "not_supported", "message": "External 1C access is disabled in this SQL-only adapter version."}
return result
if config.get("lock_mode") == "manual":
result["status"] = "manual_workflow"
result["available"] = None
result["probe"] = {"status": "not_applicable", "message": "Manual lock mode does not require Designer or a repository runner. Use repository.lock.plan."}
return result
timeout_seconds = int(payload.get("timeout_seconds") or 60)
executed = _execute_repository(base_id, config, "report", timeout_seconds)
result["probe"] = executed
@@ -478,10 +636,11 @@ def status(payload: dict[str, Any]) -> dict[str, Any]:
def lock(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
layer_id = development_layer_id(payload)
config, error = repository_config(base_id, layer_id)
if error:
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, **error}
if config.get("lock_mode") == "manual":
if manual_confirmation_required(config):
return {
"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id,
"status": "manual_action_required", "plan": lock_plan(payload), "next_method": METHOD_CONFIRM,
@@ -502,17 +661,18 @@ def lock(payload: dict[str, Any]) -> dict[str, Any]:
session_id = "rlock-" + uuid.uuid4().hex
state = _read_state()
sessions = state.setdefault("sessions", {})
sessions[session_id] = {"base_id": base_id, "layer": layer, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
sessions[session_id] = {"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
_write_state(state)
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "acquired", "lock_session_id": session_id, "acquired": plan["lock_objects"], "backend": config.get("backend"), "execution": executed}
def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
layer_id = development_layer_id(payload)
config, error = repository_config(base_id, layer_id)
if error:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, **error}
if config.get("lock_mode") != "manual":
if not manual_confirmation_required(config):
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "manual_lock_mode_required"}
if not external_1c_enabled() and not str(payload.get("request_id") or "").strip():
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_required", "next_method": METHOD_LOCK_REQUEST}
@@ -525,31 +685,73 @@ def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_not_pending", "request_id": request_id}
effective_payload = dict(payload)
if isinstance(request, dict):
layer_id = str(request.get("layer_id") or request.get("layer") or "base")
config, error = repository_config(base_id, layer_id)
if error:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, **error}
if not manual_confirmation_required(config):
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "manual_lock_mode_required"}
effective_payload["objects"] = [str(item) for item in request.get("objects") or []]
effective_payload["layer_id"] = layer_id
effective_payload["sql_resolution"] = request.get("sql_resolution") if isinstance(request.get("sql_resolution"), list) else []
if payload.get("user_confirmed_locked") is not True:
response: dict[str, Any] = {
"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM,
"base_id": base_id, "layer_id": layer_id, "status": "confirmation_required",
"error": "user_confirmed_locked_required",
"required_fields": ["base_id", "request_id", "user_confirmed_locked"],
"next_call": manual_confirmation_next_call(base_id, request_id),
}
if isinstance(request, dict):
response.update({
"request_id": request_id,
"objects": list(request.get("objects") or []),
"repository_user": str(request.get("repository_user") or config.get("repository_user") or "") or None,
"sql_resolution": effective_payload["sql_resolution"],
})
return response
plan = lock_plan(effective_payload)
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
plan["status"] = "ready"
if plan.get("status") != "ready":
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "plan": plan}
if payload.get("user_confirmed_locked") is not True:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "confirmation_required", "plan": plan}
requested_repository_user = str(payload.get("confirmed_repository_user") or payload.get("repository_user") or "").strip()
expected_repository_user = str(config.get("repository_user") or "").strip()
request_repository_user = str(request.get("repository_user") or "").strip() if isinstance(request, dict) else ""
confirmed_repository_user = requested_repository_user or request_repository_user or expected_repository_user
if not confirmed_repository_user:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "confirmation_required", "error": "repository_user_confirmation_required", "plan": plan}
if expected_repository_user and confirmed_repository_user.casefold() != expected_repository_user.casefold():
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "repository_user_mismatch", "expected_repository_user": expected_repository_user}
session_id = "rlock-" + uuid.uuid4().hex
state.setdefault("sessions", {})[session_id] = {
"base_id": base_id, "layer": str(config.get("layer") or "base"), "backend": config.get("backend"),
"base_id": base_id, "layer": layer_id, "layer_id": layer_id, "backend": config.get("backend"),
"objects": plan["lock_objects"], "created_at": time.time(), "status": "manual_confirmed",
"verification": "user_confirmation_only", "automatically_verified": False,
"repository_user": confirmed_repository_user,
**({"request_id": request_id} if request_id else {}),
}
if isinstance(request, dict):
request["status"] = "confirmed_by_user"
request["confirmed_at"] = time.time()
request["lock_session_id"] = session_id
_audit(state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"])
_audit(state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"], repository_user=confirmed_repository_user)
_write_state(state)
return {
"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id,
"status": "manual_confirmed", "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"],
"status": "manual_confirmed", "layer_id": layer_id, "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"],
"capture_state": "manually_confirmed",
"native_lock_state": "unknown",
"native_lock_verification": "not_available_via_infobase_sql",
"automatically_verified": False,
"repository_user": confirmed_repository_user,
"write_context": {
"lock_session_id": session_id,
"repository_object": plan["lock_objects"][0] if len(plan["lock_objects"]) == 1 else None,
"layer_id": layer_id,
"locked_objects": plan["lock_objects"],
"instruction": "Copy lock_session_id into metadata.write.preflight and code.write. For one object, repository_object is supplied for clients that do not resolve module ownership themselves.",
},
"warning": "Адаптер принял явное подтверждение пользователя, но не проверял захват через API хранилища.",
}
@@ -604,20 +806,32 @@ def close_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
def write_gate(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
layer_id = development_layer_id(payload)
config, error = repository_config(base_id, layer_id)
if error and error.get("status") == "not_configured":
return {"required": False, "allowed": True, "status": "not_configured"}
return {"required": False, "allowed": True, "status": "not_configured", "layer_id": layer_id}
if error:
return {"required": True, "allowed": False, "status": "blocked_repository_configuration", "problem": error}
return {"required": True, "allowed": False, "status": "blocked_repository_configuration", "layer_id": layer_id, "problem": error}
if config.get("mode") == "none" or config.get("connection_state") == "not_configured":
return {"required": False, "allowed": True, "status": "repository_not_connected", "layer_id": layer_id}
if config.get("mode") == "unknown" or config.get("connection_state") == "unknown":
return {"required": True, "allowed": False, "status": "blocked_repository_connection_unknown", "layer_id": layer_id}
session_id = str(payload.get("lock_session_id") or "").strip()
if not session_id:
return {
"required": True, "allowed": False, "status": "needs_repository_lock",
"backend": config.get("backend"), "next_method": METHOD_LOCK_PLAN,
"backend": config.get("backend"), "layer_id": layer_id, "next_method": METHOD_LOCK_PLAN,
}
session = (_read_state().get("sessions") or {}).get(session_id)
if not isinstance(session, dict) or session.get("base_id") != base_id or session.get("status") not in {"acquired", "manual_confirmed"}:
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "lock_session_id": session_id}
if not isinstance(session, dict):
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_session_not_found", "layer_id": layer_id, "lock_session_id": session_id}
if session.get("base_id") != base_id:
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_session_base_mismatch", "layer_id": layer_id, "lock_session_id": session_id, "session_base_id": session.get("base_id")}
session_layer_id = str(session.get("layer_id") or session.get("layer") or "base")
if session_layer_id != layer_id:
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_layer_mismatch", "layer_id": layer_id, "session_layer_id": session_layer_id, "lock_session_id": session_id}
if session.get("status") not in {"acquired", "manual_confirmed"}:
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "error": "lock_session_inactive", "layer_id": layer_id, "lock_session_id": session_id, "session_status": session.get("status")}
target = payload.get("target") if isinstance(payload.get("target"), dict) else {}
requested = ""
for value in (
@@ -641,15 +855,67 @@ def write_gate(payload: dict[str, Any]) -> dict[str, Any]:
if requested.casefold() not in {item.casefold() for item in locked}:
return {
"required": True, "allowed": False, "status": "blocked_repository_scope_mismatch",
"lock_session_id": session_id, "requested_object": requested, "locked_objects": locked,
"error": "lock_object_mismatch", "lock_session_id": session_id, "requested_object": requested, "locked_objects": locked,
}
return {
"required": True, "allowed": True, "status": "ready", "backend": config.get("backend"),
"required": True, "allowed": True, "status": "ready", "backend": config.get("backend"), "layer_id": layer_id,
"lock_session_id": session_id, "requested_object": requested, "objects": locked,
"verification": "automatic" if session.get("status") == "acquired" else "user_confirmation_only",
}
def support_gate(payload: dict[str, Any]) -> dict[str, Any]:
"""Evaluate the independently configured support policy for one exact target.
Repository scope may collapse a child to its development owner. Support scope
intentionally does not: a form or another child can have its own support rule.
"""
base_id = str(payload.get("base_id") or "").strip()
layer_id = development_layer_id(payload)
base_item, base_error = _base_settings(base_id)
if base_error:
if base_error.get("status") == "not_configured":
return {"required": False, "allowed": True, "status": "support_not_configured_legacy", "layer_id": layer_id, "source": "legacy_configuration"}
return {"required": True, "allowed": False, "status": "blocked_support_configuration", "layer_id": layer_id, "problem": base_error}
layers = base_item.get("development_layers") if isinstance(base_item.get("development_layers"), dict) else None
if layers is None:
return {"required": False, "allowed": True, "status": "support_not_configured_legacy", "layer_id": layer_id, "source": "legacy_configuration"}
layer = layers.get(layer_id)
if not isinstance(layer, dict):
return {"required": True, "allowed": False, "status": "blocked_support_layer_unknown", "layer_id": layer_id}
support = layer.get("support")
if not isinstance(support, dict):
return {"required": True, "allowed": False, "status": "blocked_support_unknown", "layer_id": layer_id, "source": "development_layer_configuration"}
mode = str(support.get("mode") or "unknown").strip().casefold()
if mode not in SUPPORTED_SUPPORT_MODES:
return {"required": True, "allowed": False, "status": "blocked_support_configuration", "layer_id": layer_id, "problem": {"message": "support.mode must be none, editable, locked, rules, or unknown."}}
if mode == "none":
return {"required": False, "allowed": True, "status": "not_on_support", "layer_id": layer_id, "mode": mode}
if mode == "editable":
return {"required": True, "allowed": True, "status": "support_editable", "layer_id": layer_id, "mode": mode}
if mode == "locked":
return {"required": True, "allowed": False, "status": "blocked_by_support", "layer_id": layer_id, "mode": mode}
target = payload.get("target") if isinstance(payload.get("target"), dict) else {}
scope = str(target.get("support_scope") or target.get("canonical_path") or target.get("path") or payload.get("support_scope") or payload.get("canonical_path") or payload.get("path") or payload.get("ref") or payload.get("object") or "").strip()
if mode == "rules" and scope:
rules = support.get("rules") if isinstance(support.get("rules"), dict) else {}
candidates = []
folded = scope.casefold()
for rule_scope, rule_value in rules.items():
key = str(rule_scope).strip()
if key and (folded == key.casefold() or folded.startswith(key.casefold() + ".")):
candidates.append((len(key), key, str(rule_value or "unknown").strip().casefold()))
if candidates:
_, matched_scope, rule = max(candidates)
allowed = rule in {"editable", "vendor_editable_support_preserved", "not_supported"}
return {
"required": True, "allowed": allowed,
"status": "support_rule_editable" if allowed else ("blocked_by_support_rule" if rule in {"locked", "vendor_not_editable"} else "blocked_support_unknown"),
"layer_id": layer_id, "mode": mode, "scope": scope, "matched_scope": matched_scope, "rule": rule,
}
return {"required": True, "allowed": False, "status": "blocked_support_unknown", "layer_id": layer_id, "mode": mode, "scope": scope or None}
def commit_plan(payload: dict[str, Any]) -> dict[str, Any]:
session_id = str(payload.get("lock_session_id") or "").strip()
session = (_read_state().get("sessions") or {}).get(session_id)
@@ -679,7 +945,7 @@ def commit(payload: dict[str, Any]) -> dict[str, Any]:
state = _read_state()
session = (state.get("sessions") or {}).get(session_id)
base_id = str(session.get("base_id") or "")
config, error = repository_config(base_id)
config, error = repository_config(base_id, str(session.get("layer_id") or session.get("layer") or "base"))
if error:
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "lock_session_id": session_id, **error}
with _base_lock(base_id, str(session.get("layer") or "base")):
@@ -704,7 +970,7 @@ def unlock(payload: dict[str, Any]) -> dict[str, Any]:
if not payload.get("allow_repository_unlock") is True:
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "explicit_repository_unlock_required", "lock_session_id": session_id}
base_id = str(session.get("base_id") or "")
config, error = repository_config(base_id)
config, error = repository_config(base_id, str(session.get("layer_id") or session.get("layer") or "base"))
if error:
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "lock_session_id": session_id, **error}
with _base_lock(base_id, str(session.get("layer") or "base")):
+102 -23
View File
@@ -104,6 +104,18 @@ OBJECT_SELECTOR_SCHEMA_PROPERTIES = {
}
CACHE_POLICIES = {"none", "ttl", "snapshot", "stale_while_revalidate"}
SOURCE_STATES = {"applied", "working", "all"}
CONFIGURATION_VIEW_TO_SOURCE_STATE = {
"effective": "working",
"effective_working": "working",
"designer": "working",
"working": "working",
"runtime": "applied",
"runtime_applied": "applied",
"applied": "applied",
"compare": "all",
"comparison": "all",
"both": "all",
}
REST_STATE_BY_SOURCE_STATE = {
"applied": "active",
"working": "working",
@@ -176,14 +188,24 @@ TOOLS = [
"payload.base_id is required; get it from user/project context or check a concrete base with onec_health first. "
"If you already have module_ref/read_selector, prefer direct read methods before global search. "
"Search results include read_selector.method; reuse that selector directly for the next read call. "
"For a name search across metadata objects, forms, attributes, commands, templates, routines, and extension definitions, use metadata.definition.find; "
"use code.search only when the query is BSL text. Scope extension objects with extension.objects.find or metadata.definition.find areas=extensions, never by SQL table names. "
"For unresolved module owners, inspect diagnostics.owner_resolution and adjust the object selector "
"(ref, kind/name/guid, or object_type/object_name/object_guid) or owner_scan_limit. "
"For programming in Configurator/designer, use source_state=working to read saved-but-not-applied metadata; "
"use source_state=applied for active metadata or source_state=all to compare both. "
"The default agent view is configuration_view=effective_working with source_state=working: the logical Designer snapshot, with working changes and extension layers preferred; "
"it becomes executable after configuration update, not necessarily now. Use configuration_view=runtime_applied for code executable now, or compare to inspect both. "
"Do not select Config/ConfigSave tables in ordinary programming calls. "
"For saved-state methods, select base_saved_state or extension_saved_state with layer and identify objects by ref or kind/name; "
"use table/file_name/module_ref only when continuing an explicit include_storage diagnostic result. "
"Before writes, call metadata.write.preflight when you need a read-only route/freshness check; it reports "
"ready, needs_prepare, needs_resolution, or blocked and never applies SQL writes. "
"Repository manual-capture protocol: when repository.lock.request or repository.lock.request.status returns "
"status=pending_user_lock, do not invent confirmation fields. After the user confirms the exact object is "
"captured in Configurator, call the returned next_call.method using next_call.params as this tool's payload. "
"The successful confirmation returns write_context; forward it unchanged as payload.repository_lock (or copy its fields to the payload top level) for write preflight and write calls. "
"For BSL edits, prefer high-level metadata.write: pass a 1C canonical path, routine_text, and routine_operation; "
"the adapter prepares saved-state when needed, saves into working/saved-state metadata, and never activates it. "
"For a scheduled-job schedule, use metadata.write with target.kind=schedule and target.ref such as РегламентныеЗадания.ОбменДанными; pass named schedule fields instead of GUIDs or tree paths. "
"For adding a form command with a visible button and handler routine, use metadata.form.command_button.write. "
"Use code.write only as a compatibility shortcut for simple module edits. "
"Long metadata calls return a job_id quickly; poll it with method mcp.job.get."
@@ -362,6 +384,16 @@ TOOLS = [
"routine_text": "<full-procedure-or-function-text>",
},
},
{
"method": "metadata.write",
"payload": {
"base_id": "<base_id-from-project-context>",
"target": {"kind": "schedule", "ref": "РегламентныеЗадания.<scheduled-job-name>"},
"schedule": {"begin_time": "09:00:00", "week_days": [1, 2, 3, 4, 5]},
"allow_saved_state_write": True,
"mode": "plan",
},
},
{
"method": "metadata.form.command_button.write",
"payload": {
@@ -417,9 +449,9 @@ TOOLS = [
"method": "metadata.saved_state.prepare",
"payload": {
"base_id": "<base_id-from-project-context>",
"target_table": "ConfigCASSave",
"object_type": "<metadata-kind>",
"object_name": "<metadata-object-name>",
"layer": "extension_saved_state",
"ref": "<metadata-kind>.<metadata-object-name>",
"extension": "<extension-name>",
"mode": "plan",
},
},
@@ -427,8 +459,9 @@ TOOLS = [
"method": "metadata.saved_state.diff",
"payload": {
"base_id": "<base_id-from-project-context>",
"table": "ConfigCASSave",
"file_name": "<saved-state-file-name>",
"ref": "<metadata-kind>.<metadata-object-name>",
"module_ordinal": 1,
"extension": "<extension-name-if-needed>",
"max_text_diff_lines": 80,
},
},
@@ -436,7 +469,7 @@ TOOLS = [
"method": "metadata.saved_state.status",
"payload": {
"base_id": "<base_id-from-project-context>",
"table": "ConfigCASSave",
"layer": "extension_saved_state",
"limit": 200,
},
},
@@ -444,6 +477,7 @@ TOOLS = [
"method": "metadata.saved_state.changes.list",
"payload": {
"base_id": "<base_id-from-project-context>",
"layer": "extension_saved_state",
"limit": 200,
"include_context": True,
"group_by_context": True,
@@ -990,15 +1024,17 @@ TOOLS = [
},
{
"name": "access_object_explain",
"description": "Explain who can see a BSP-protected object/record by resolving object access keys to groups, user sets, and users.",
"description": "Explain who can see a BSP-protected data record. Prefer public object_ref plus record_ref; legacy raw BSP fields remain available.",
"inputSchema": {
"type": "object",
"required": ["base_id"],
"properties": {
"base_id": {"type": "string", "description": "Concrete 1C base id."},
"object": {"type": "string", "description": "Normalized object ref from access_object_keys.object."},
"object_id": {"type": "string", "description": "Data record id from access_object_keys.object_id."},
"object_sql_number": {"type": "integer"},
"object_ref": {"type": "string", "description": "Public metadata ref, for example Справочники.Номенклатура."},
"record_ref": {"type": "string", "description": "Concrete 1C application-data record reference."},
"object": {"type": "string", "description": "Legacy raw BSP object value."},
"object_id": {"type": "string", "description": "Legacy raw BSP object_id; prefer record_ref."},
"object_sql_number": {"type": "integer", "description": "Legacy physical selector; prefer object_ref."},
"access_key": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000},
"subject_limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000},
@@ -1010,20 +1046,25 @@ TOOLS = [
},
{
"name": "access_keys_query",
"description": "Page through BSP access key registers by group, user set, object, access set, or all. Use for data restriction diagnostics, not for metadata-object role rights.",
"description": "Page through BSP access key registers. kind selects the query area, not a metadata kind; in object mode prefer object_ref plus optional record_ref.",
"inputSchema": {
"type": "object",
"required": ["base_id"],
"properties": {
"base_id": {"type": "string", "description": "Concrete 1C base id."},
"kind": {"type": "string", "description": "group, user, object, access_set, or all."},
"kind": {"type": "string", "description": "Query area: group, user_set, object, set, or all. This is not a 1C metadata kind."},
"area": {"type": "string", "description": "Alias of kind."},
"group": {"type": "string"},
"user": {"type": "string"},
"user_set": {"type": "string"},
"object": {"type": "string"},
"object_id": {"type": "string"},
"object_sql_number": {"type": "integer"},
"object_ref": {"type": "string", "description": "Public metadata ref, for example Справочники.Номенклатура."},
"record_ref": {"type": "string", "description": "Concrete 1C application-data record reference."},
"object": {"type": "string", "description": "Legacy raw BSP object value."},
"object_id": {"type": "string", "description": "Legacy raw BSP object_id; prefer record_ref."},
"object_sql_number": {"type": "integer", "description": "Legacy physical selector; prefer object_ref."},
"access_key": {"type": "string"},
"resolve_records": {"type": "boolean", "default": False},
"max_resolved_records": {"type": "integer", "minimum": 0, "maximum": 5000, "default": 200},
"limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000},
"offset": {"type": "integer", "minimum": 0, "default": 0},
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 120, "default": 120},
@@ -1033,15 +1074,17 @@ TOOLS = [
},
{
"name": "access_object_keys_resolve",
"description": "Return BSP object access-key rows and resolve object records when possible. Accepts object names/public refs and internal ids.",
"description": "Return BSP object access-key rows and resolve record presentations. Prefer public object_ref plus optional record_ref.",
"inputSchema": {
"type": "object",
"required": ["base_id"],
"properties": {
"base_id": {"type": "string", "description": "Concrete 1C base id."},
"object": {"type": "string"},
"object_id": {"type": "string"},
"object_sql_number": {"type": "integer"},
"object_ref": {"type": "string", "description": "Public metadata ref, for example Справочники.Номенклатура."},
"record_ref": {"type": "string", "description": "Concrete 1C application-data record reference."},
"object": {"type": "string", "description": "Legacy raw BSP object value."},
"object_id": {"type": "string", "description": "Legacy raw BSP object_id; prefer record_ref."},
"object_sql_number": {"type": "integer", "description": "Legacy physical selector; prefer object_ref."},
"access_key": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 20000, "default": 1000},
"offset": {"type": "integer", "minimum": 0, "default": 0},
@@ -1211,7 +1254,22 @@ def normalize_source_state(value: Any, source_mode: str) -> str:
return "working"
if value_text in SOURCE_STATES:
return value_text
return "working" if source_mode == "designer" else "applied"
# Agents program against the logical Designer configuration by default.
# Runtime remains explicit through source_mode=runtime or source_state=applied.
return "applied" if source_mode == "runtime" else "working"
def normalize_configuration_view(value: Any, source_state: str) -> tuple[str, str]:
view = str(value or "").strip().casefold()
if not view:
return ("effective_working" if source_state == "working" else "runtime_applied" if source_state == "applied" else "compare", source_state)
mapped = CONFIGURATION_VIEW_TO_SOURCE_STATE.get(view)
if not mapped:
return "", ""
if source_state and source_state != mapped:
return "", ""
canonical = "effective_working" if mapped == "working" else "runtime_applied" if mapped == "applied" else "compare"
return canonical, mapped
def normalize_cache_policy(value: Any, source_mode: str) -> str:
@@ -2213,7 +2271,15 @@ def apply_freshness_request_policy(payload: dict[str, Any], method: str) -> dict
if method == "code.write":
source_mode = "designer"
cache_policy = normalize_cache_policy(payload.get("cache_policy"), source_mode)
source_state = normalize_source_state(payload.get("source_state"), source_mode)
explicit_source_state = normalize_source_state(payload.get("source_state"), source_mode) if payload.get("source_state") is not None else ""
configuration_view, source_state = normalize_configuration_view(payload.get("configuration_view"), explicit_source_state)
if not configuration_view:
transformed = dict(payload)
transformed["_configuration_view_error"] = "configuration_view conflicts with source_state or is unsupported."
return transformed
if not source_state:
source_state = normalize_source_state(None, source_mode)
configuration_view, source_state = normalize_configuration_view(payload.get("configuration_view"), source_state)
if method == "code.write":
source_state = "working"
cache_policy = "none"
@@ -2221,6 +2287,7 @@ def apply_freshness_request_policy(payload: dict[str, Any], method: str) -> dict
transformed = dict(payload)
transformed["source_mode"] = source_mode
transformed["source_state"] = source_state
transformed["configuration_view"] = configuration_view
transformed["cache_policy"] = cache_policy
if method in REST_STATE_METHODS and "state" not in transformed:
transformed["state"] = REST_STATE_BY_SOURCE_STATE.get(source_state, "working")
@@ -2878,7 +2945,19 @@ def metadata_write_code_guardrail(method: str, payload: dict[str, Any]) -> dict[
def run_or_enqueue_adapter_method(method: str, payload: dict[str, Any]) -> Any:
request_start = now_ts()
request_id = uuid.uuid4().hex
if method == "code.search" and payload.get("extension_guid") in {None, ""}:
payload = dict(payload)
payload.pop("extension_guid", None)
request_payload = apply_freshness_request_policy(payload, method)
configuration_view_error = request_payload.pop("_configuration_view_error", None)
if configuration_view_error:
return {
"schema": "adapter_1c_mcp_policy.v1",
"status": "invalid_argument",
"method": method,
"error": "invalid_configuration_view",
"diagnostics": {"message": str(configuration_view_error)},
}
request_payload["_mcp_request_id"] = request_id
payload = request_payload
code_guardrail = metadata_write_code_guardrail(method, payload)
+8
View File
@@ -20,6 +20,10 @@ infobase.
- `xml_metadata.py`: small XML metadata extractor used as validation oracle.
- `structured_metadata.py`: evidence-based projection from Config payloads to
normalized metadata records.
- `common_command.py`: adapter-independent reverse index from
`CommonCommand.Group` to command-group membership.
- `scheduled_job.py`: adapter-independent scheduled-job schedule decoder,
named-field validator, and verified tree rebuilder.
## Current Guarantees
@@ -42,6 +46,10 @@ The parser can currently:
evidence paths.
- attach child metadata items to concrete section record paths when a declared
child-record container is present.
- resolve command-group membership from CommonCommand payloads without
requiring callers to know GUIDs or storage paths.
- decode scheduled-job schedule payloads and build guarded named-field edits,
including weekday/month collection resize without exposing tree paths.
## Non-Goals At This Layer
+37 -1
View File
@@ -9,7 +9,13 @@ from .payload import (
payload_to_text,
try_decompress,
)
from .dbnames import DBNamesRecord, parse_dbnames_bytes, parse_dbnames_file
from .dbnames import (
DBNamesRecord,
parse_dbnames_bytes,
parse_dbnames_file,
parse_dbnames_version_bytes,
parse_dbnames_version_file,
)
from .extensions import (
ExtensionZippedInfo,
ManifestEntry,
@@ -20,6 +26,23 @@ from .config_object import MetadataObjectIdentity, find_identity, parse_config_o
from .storage import StorageRoute, group_records_by_guid, storage_route, storage_routes
from .config_sections import SectionSummary, summarize_section, summarize_sections
from .xml_metadata import XmlMetadataItem, extract_xml_metadata_items, group_xml_items
from .support_rules import (
SupplierSupport,
SupportRule,
parse_parent_configurations_bytes,
parse_parent_configurations_file,
)
from .common_command import (
common_command_group_guid,
index_common_command_groups,
parse_common_command_tree,
)
from .scheduled_job import (
decode_schedule,
rebuild_schedule_tree,
schedule_layout,
schedule_write_edits,
)
__all__ = [
"BraceNode",
@@ -32,6 +55,8 @@ __all__ = [
"DBNamesRecord",
"parse_dbnames_bytes",
"parse_dbnames_file",
"parse_dbnames_version_bytes",
"parse_dbnames_version_file",
"ExtensionZippedInfo",
"ManifestEntry",
"parse_extension_manifest_bytes",
@@ -49,4 +74,15 @@ __all__ = [
"XmlMetadataItem",
"extract_xml_metadata_items",
"group_xml_items",
"SupplierSupport",
"SupportRule",
"parse_parent_configurations_bytes",
"parse_parent_configurations_file",
"common_command_group_guid",
"index_common_command_groups",
"parse_common_command_tree",
"decode_schedule",
"rebuild_schedule_tree",
"schedule_layout",
"schedule_write_edits",
]
+89
View File
@@ -0,0 +1,89 @@
"""Pure reverse index for 1C common-command group membership."""
from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from typing import Any
from .payload import decode_payload_lossless, parse_brace_text
_GUID_RE = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
def _tree_item_at_path(tree: Any, path: tuple[int, ...]) -> Any | None:
node = tree
for index in path:
if not isinstance(node, dict) or not isinstance(node.get("items"), list):
return None
items = node["items"]
if index < 0 or index >= len(items):
return None
node = items[index]
return node
def _tree_scalar_at_path(tree: Any, path: tuple[int, ...]) -> str:
node = _tree_item_at_path(tree, path)
if isinstance(node, dict) and node.get("type") in {"atom", "string"}:
return str(node.get("value") or "")
return ""
def common_command_group_guid(tree: Any) -> str | None:
"""Return the group GUID stored in an observed CommonCommand Config tree."""
body = _tree_item_at_path(tree, (1, 1, 2))
group_guid = _tree_scalar_at_path(body, (7, 1)).strip().lower()
return group_guid if _GUID_RE.fullmatch(group_guid) else None
def parse_common_command_tree(data: bytes) -> Any | None:
"""Decode a CommonCommand payload without raising on unsupported data."""
try:
decoded = decode_payload_lossless(data)
text = decoded.get("text")
if not text or "{" not in text:
return None
return parse_brace_text(text)
except Exception:
return None
def index_common_command_groups(
commands: Iterable[Mapping[str, Any]],
payloads: Mapping[str, bytes],
) -> dict[str, Any]:
"""Build ``CommandGroup GUID -> CommonCommand rows`` from Config payloads."""
command_rows = [dict(item) for item in commands]
normalized_payloads = {str(key).strip().lower(): value for key, value in payloads.items()}
groups: dict[str, list[dict[str, Any]]] = {}
source_missing = 0
undecodable = 0
unassigned = 0
for item in command_rows:
guid = str(item.get("guid") or "").strip().lower()
data = normalized_payloads.get(guid)
if not data:
source_missing += 1
continue
tree = parse_common_command_tree(data)
if tree is None:
undecodable += 1
continue
group_guid = common_command_group_guid(tree)
if group_guid is None:
unassigned += 1
continue
groups.setdefault(group_guid, []).append(item)
return {
"groups": groups,
"scanned": len(command_rows),
"source_missing": source_missing,
"undecodable": undecodable,
"unassigned": unassigned,
}
+42
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
import re
from typing import Any
from .payload import parse_brace_text, payload_to_text, scalar
@@ -77,3 +78,44 @@ def parse_dbnames_bytes(data: bytes, *, source: str = "DBNames") -> dict[str, An
def parse_dbnames_file(path: Path) -> dict[str, Any]:
return parse_dbnames_bytes(path.read_bytes(), source=path.name)
_GUID_RE = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
def parse_dbnames_version_bytes(data: bytes, *, source: str = "DBNamesVersion") -> dict[str, Any]:
"""Parse the version marker stored separately from DBNames records."""
decoded = payload_to_text(data)
text = decoded.get("text")
if text is None:
raise ValueError(f"{source}: cannot decode DBNamesVersion text")
parsed = _unwrap_bom_sequence(parse_brace_text(text))
if not (isinstance(parsed, dict) and parsed.get("type") == "list"):
raise ValueError(f"{source}: expected root list")
items = parsed.get("items") or []
if len(items) != 2:
raise ValueError(f"{source}: expected 2 root items, got {len(items)}")
if not all(isinstance(item, dict) and item.get("type") == "atom" for item in items):
actual_types = [item.get("type") if isinstance(item, dict) else type(item).__name__ for item in items]
raise ValueError(f"{source}: expected scalar marker and version, got {actual_types}")
marker_text = scalar(items[0]).strip()
try:
marker = int(marker_text)
except (TypeError, ValueError) as exc:
raise ValueError(f"{source}: marker must be an integer, got {marker_text!r}") from exc
version = scalar(items[1]).strip().lower()
if not _GUID_RE.fullmatch(version):
raise ValueError(f"{source}: version must be a GUID string, got {version!r}")
return {
"source": source,
"compression": decoded["compression"],
"encoding": decoded["encoding"],
"marker": marker,
"version": version,
}
def parse_dbnames_version_file(path: Path) -> dict[str, Any]:
return parse_dbnames_version_bytes(path.read_bytes(), source=path.name)
+451
View File
@@ -0,0 +1,451 @@
"""Pure decoder and safe tree editor for 1C scheduled-job schedules."""
from __future__ import annotations
import copy
import re
from datetime import datetime
from typing import Any
WRITABLE_SCALAR_FIELDS = {
"begin_date",
"end_date",
"begin_time",
"end_time",
"completion_time",
"completion_interval",
"repeat_period_in_day",
"repeat_pause",
"week_day_in_month",
"day_in_month",
"weeks_period",
"days_repeat_period",
}
WRITABLE_LIST_FIELDS = {"week_days", "months"}
INTEGER_RANGES = {
"completion_interval": (0, 2_147_483_647),
"repeat_period_in_day": (0, 2_147_483_647),
"repeat_pause": (0, 2_147_483_647),
"week_day_in_month": (0, 5),
"day_in_month": (0, 31),
"weeks_period": (0, 2_147_483_647),
"days_repeat_period": (0, 2_147_483_647),
}
def _scalar(node: Any) -> str:
if not isinstance(node, dict):
return str(node or "")
if node.get("type") in {"atom", "string"}:
return str(node.get("value") or "")
return ""
def schedule_datetime(raw: str) -> tuple[str | None, str | None]:
if not re.fullmatch(r"\d{14}", raw):
return None, None
return (
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]}",
f"{raw[8:10]}:{raw[10:12]}:{raw[12:14]}",
)
def schedule_layout(tree: Any) -> dict[str, Any]:
items = tree.get("items") if isinstance(tree, dict) and isinstance(tree.get("items"), list) else []
raw = [_scalar(item) for item in items]
if len(raw) < 13:
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "The SQL schedule payload is shorter than the supported format."},
}
week_day_count = int(raw[8]) if re.fullmatch(r"-?\d+", raw[8]) else None
cursor = 9
if week_day_count is None or week_day_count < 0 or cursor + week_day_count > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "Invalid weekday collection in the SQL schedule payload."},
}
week_days_start = cursor
cursor += week_day_count
if cursor + 3 > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "The SQL schedule payload has no month collection header."},
}
week_day_in_month_index = cursor
day_in_month_index = cursor + 1
month_count_index = cursor + 2
month_count = int(raw[month_count_index]) if re.fullmatch(r"-?\d+", raw[month_count_index]) else None
cursor += 3
if month_count is None or month_count < 0 or cursor + month_count > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "Invalid month collection in the SQL schedule payload."},
}
months_start = cursor
cursor += month_count
if cursor + 2 > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "The SQL schedule payload has no repeat-period tail."},
}
return {
"status": "ok",
"raw": raw,
"indexes": {
"begin_date": 0,
"end_date": 1,
"begin_time": 2,
"end_time": 3,
"completion_time": 4,
"completion_interval": 5,
"repeat_period_in_day": 6,
"repeat_pause": 7,
"week_day_count": 8,
"week_days": list(range(week_days_start, week_days_start + week_day_count)),
"week_day_in_month": week_day_in_month_index,
"day_in_month": day_in_month_index,
"month_count": month_count_index,
"months": list(range(months_start, months_start + month_count)),
"weeks_period": cursor,
"days_repeat_period": cursor + 1,
},
"cursor": cursor + 2,
}
def decode_schedule(tree: Any, *, include_storage: bool = False) -> dict[str, Any]:
layout = schedule_layout(tree)
if layout.get("status") != "ok":
return layout
raw = layout["raw"]
indexes = layout["indexes"]
def integer(field: str) -> int | None:
value = raw[indexes[field]]
return int(value) if re.fullmatch(r"-?\d+", value) else None
begin_date, _ = schedule_datetime(raw[indexes["begin_date"]])
end_date, _ = schedule_datetime(raw[indexes["end_date"]])
_, begin_time = schedule_datetime(raw[indexes["begin_time"]])
_, end_time = schedule_datetime(raw[indexes["end_time"]])
_, completion_time = schedule_datetime(raw[indexes["completion_time"]])
result = {
"status": "ok",
"begin_date": begin_date,
"end_date": end_date,
"begin_time": begin_time,
"end_time": end_time,
"completion_time": completion_time,
"completion_interval": integer("completion_interval"),
"repeat_period_in_day": integer("repeat_period_in_day"),
"repeat_pause": integer("repeat_pause"),
"week_days": [int(raw[index]) for index in indexes["week_days"] if raw[index].isdigit()],
"week_day_in_month": integer("week_day_in_month"),
"day_in_month": integer("day_in_month"),
"months": [int(raw[index]) for index in indexes["months"] if raw[index].isdigit()],
"weeks_period": integer("weeks_period"),
"days_repeat_period": integer("days_repeat_period"),
"evidence": "live_sql_config_schedule_decoder",
}
if include_storage:
result["storage"] = {
"format": "scheduled_job_config_suffix_0",
"raw_values": raw,
"trailing_values": raw[int(layout["cursor"]) :],
}
return result
def rebuild_schedule_tree(tree: Any, requested: dict[str, Any]) -> dict[str, Any]:
layout = schedule_layout(tree)
if layout.get("status") != "ok":
return layout
raw = list(layout["raw"])
indexes = layout["indexes"]
current = decode_schedule(tree)
for field in WRITABLE_SCALAR_FIELDS:
if field not in requested:
continue
value = requested[field]
index = int(indexes[field])
if field in {"begin_date", "end_date"}:
raw[index] = str(value).replace("-", "") + (
raw[index][8:] if re.fullmatch(r"\d{14}", raw[index]) else "000000"
)
elif field in {"begin_time", "end_time", "completion_time"}:
raw[index] = (
raw[index][:8] if re.fullmatch(r"\d{14}", raw[index]) else "00010101"
) + str(value).replace(":", "")
else:
raw[index] = str(value)
week_days = list(requested.get("week_days", current.get("week_days") or []))
months = list(requested.get("months", current.get("months") or []))
rebuilt_raw = [
*raw[:8],
str(len(week_days)),
*(str(value) for value in week_days),
raw[int(indexes["week_day_in_month"])],
raw[int(indexes["day_in_month"])],
str(len(months)),
*(str(value) for value in months),
raw[int(indexes["weeks_period"])],
raw[int(indexes["days_repeat_period"])],
*raw[int(layout["cursor"]) :],
]
rebuilt_tree = copy.deepcopy(tree)
rebuilt_tree["items"] = [{"type": "atom", "value": value} for value in rebuilt_raw]
verification = decode_schedule(rebuilt_tree)
if verification.get("status") != "ok":
return {
"status": "error",
"error": "schedule_rebuild_verification_failed",
"diagnostics": {"message": "The rebuilt scheduled-job tree did not pass the schedule decoder."},
}
for field, expected in requested.items():
if verification.get(field) != expected:
return {
"status": "error",
"error": "schedule_rebuild_verification_failed",
"field": field,
"diagnostics": {
"message": "A named schedule field changed during tree rebuild verification.",
"expected": expected,
"actual": verification.get(field),
},
}
return {
"status": "ok",
"tree": rebuilt_tree,
"schedule": verification,
"old_counts": {
"week_days": len(indexes["week_days"]),
"months": len(indexes["months"]),
},
"new_counts": {
"week_days": len(week_days),
"months": len(months),
},
}
def schedule_write_edits(tree: Any, requested: Any) -> dict[str, Any]:
if not isinstance(requested, dict) or not requested:
return {
"status": "invalid_argument",
"error": "schedule_required",
"diagnostics": {"message": "schedule must be a non-empty JSON object."},
}
unknown = sorted(set(requested) - WRITABLE_SCALAR_FIELDS - WRITABLE_LIST_FIELDS)
if unknown:
return {
"status": "invalid_argument",
"error": "unsupported_schedule_fields",
"diagnostics": {
"message": "The request contains unsupported scheduled-job fields.",
"fields": unknown,
"allowed_fields": sorted(WRITABLE_SCALAR_FIELDS | WRITABLE_LIST_FIELDS),
},
}
layout = schedule_layout(tree)
if layout.get("status") != "ok":
return layout
current = decode_schedule(tree)
raw = layout["raw"]
indexes = layout["indexes"]
normalized: dict[str, Any] = {}
edits: list[dict[str, Any]] = []
resized_collections: list[str] = []
for field, value in requested.items():
if field in {"begin_date", "end_date"}:
if value is None:
compact = "00010101"
elif isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
try:
datetime.fromisoformat(value)
except ValueError:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be a real ISO date YYYY-MM-DD."},
}
compact = value.replace("-", "")
else:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be an ISO date YYYY-MM-DD or null."},
}
index = int(indexes[field])
encoded = compact + (raw[index][8:] if re.fullmatch(r"\d{14}", raw[index]) else "000000")
normalized[field] = "0001-01-01" if value is None else value
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
continue
if field in {"begin_time", "end_time", "completion_time"}:
if value is None:
compact = "000000"
elif isinstance(value, str) and re.fullmatch(r"\d{2}:\d{2}:\d{2}", value):
try:
datetime.strptime(value, "%H:%M:%S")
except ValueError:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be a real time HH:MM:SS."},
}
compact = value.replace(":", "")
else:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be a time HH:MM:SS or null."},
}
index = int(indexes[field])
encoded = (
raw[index][:8] if re.fullmatch(r"\d{14}", raw[index]) else "00010101"
) + compact
normalized[field] = "00:00:00" if value is None else value
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
continue
if field in WRITABLE_LIST_FIELDS:
maximum = 7 if field == "week_days" else 12
if (
not isinstance(value, list)
or any(
isinstance(item, bool)
or not isinstance(item, int)
or item < 1
or item > maximum
for item in value
)
or len(set(value)) != len(value)
):
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {
"message": f"{field} must be a JSON array of unique integers from 1 to {maximum}."
},
}
field_indexes = list(indexes[field])
if len(value) != len(field_indexes):
resized_collections.append(field)
normalized[field] = list(value)
continue
normalized[field] = list(value)
for index, item in zip(field_indexes, value):
encoded = str(item)
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
continue
minimum, maximum = INTEGER_RANGES[field]
if isinstance(value, bool) or not isinstance(value, int) or value < minimum or value > maximum:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {
"message": f"{field} must be a JSON integer from {minimum} to {maximum}."
},
}
index = int(indexes[field])
encoded = str(value)
normalized[field] = value
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
if resized_collections:
rebuilt = rebuild_schedule_tree(tree, normalized)
if rebuilt.get("status") != "ok":
return rebuilt
edits = [
{
"replace_root": rebuilt["tree"],
"fields": sorted(normalized),
"resized_collections": sorted(resized_collections),
"old_counts": rebuilt["old_counts"],
"new_counts": rebuilt["new_counts"],
}
]
return {
"status": "ok",
"current": current,
"requested": normalized,
"edits": edits,
"counts": {
"requested_fields": len(normalized),
"edits": len(edits),
"resized_collections": len(resized_collections),
},
}
# Compatibility aliases retained by adapter_1c_server and existing clients.
config_schedule_datetime = schedule_datetime
scheduled_job_schedule_layout = schedule_layout
scheduled_job_sql_schedule = decode_schedule
scheduled_job_schedule_rebuild_tree = rebuild_schedule_tree
scheduled_job_schedule_write_edits = schedule_write_edits
SCHEDULED_JOB_WRITABLE_SCALAR_FIELDS = WRITABLE_SCALAR_FIELDS
SCHEDULED_JOB_WRITABLE_LIST_FIELDS = WRITABLE_LIST_FIELDS
SCHEDULED_JOB_INTEGER_RANGES = INTEGER_RANGES
__all__ = [
"INTEGER_RANGES",
"WRITABLE_LIST_FIELDS",
"WRITABLE_SCALAR_FIELDS",
"config_schedule_datetime",
"decode_schedule",
"rebuild_schedule_tree",
"schedule_datetime",
"schedule_layout",
"schedule_write_edits",
"scheduled_job_schedule_layout",
"scheduled_job_schedule_rebuild_tree",
"scheduled_job_schedule_write_edits",
"scheduled_job_sql_schedule",
]
+140
View File
@@ -0,0 +1,140 @@
"""Decoder for exported 1C ``ParentConfigurations.bin`` support rules.
The SQL representation of these data is platform-private and may differ from
the exported representation. Callers must therefore pass bytes from a
positively identified source; this module deliberately does not discover a
source or infer that missing data means "not on support".
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
import re
from typing import Any
from .payload import parse_brace_text, payload_to_text, scalar
GUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
OBJECT_RULES = {
0: "not_editable",
1: "editable_support_preserved",
2: "not_supported",
}
@dataclass(frozen=True)
class SupportRule:
object_guid: str
rule_code: int
rule: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class SupplierSupport:
configuration_guid: str
general_mode_code: int
general_mode: str
version: str
producer: str
name: str
declared_object_count: int
rules: tuple[SupportRule, ...]
def to_dict(self) -> dict[str, Any]:
result = asdict(self)
result["rules"] = [rule.to_dict() for rule in self.rules]
return result
def _root_items(data: bytes, source: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
decoded = payload_to_text(data)
text = decoded.get("text")
if text is None:
raise ValueError(f"{source}: cannot decode support rules text")
root = parse_brace_text(text)
if not (isinstance(root, dict) and root.get("type") == "list"):
raise ValueError(f"{source}: expected root list")
return list(root.get("items") or []), decoded
def _integer(items: list[dict[str, Any]], index: int, source: str, field: str) -> int:
try:
return int(scalar(items[index]))
except (IndexError, TypeError, ValueError) as exc:
raise ValueError(f"{source}: invalid {field} at item {index}") from exc
def parse_parent_configurations_bytes(
data: bytes,
*,
source: str = "ParentConfigurations.bin",
) -> dict[str, Any]:
"""Decode a positively identified exported support-rules payload."""
items, decoded = _root_items(data, source)
if len(items) < 3:
raise ValueError(f"{source}: support rules header is incomplete")
format_marker = _integer(items, 0, source, "format marker")
if format_marker != 6:
raise ValueError(f"{source}: expected format marker 6, got {format_marker}")
supplier_count = _integer(items, 2, source, "supplier count")
if supplier_count < 0:
raise ValueError(f"{source}: supplier count must not be negative")
suppliers: list[SupplierSupport] = []
position = 3
for supplier_index in range(supplier_count):
if position + 6 >= len(items):
raise ValueError(f"{source}: supplier {supplier_index} header is incomplete")
configuration_guid = scalar(items[position]).lower()
if not GUID_RE.fullmatch(configuration_guid):
raise ValueError(f"{source}: supplier {supplier_index} configuration GUID is invalid")
general_code = _integer(items, position + 1, source, "general support mode")
object_count = _integer(items, position + 6, source, "object count")
if object_count < 0:
raise ValueError(f"{source}: supplier {supplier_index} object count must not be negative")
object_position = position + 7
rules: list[SupportRule] = []
for object_index in range(object_count):
current = object_position + object_index * 4
if current + 3 >= len(items):
raise ValueError(f"{source}: supplier {supplier_index} object {object_index} is incomplete")
rule_code = _integer(items, current, source, "object support rule")
object_guid = scalar(items[current + 2]).lower()
if rule_code not in OBJECT_RULES:
raise ValueError(f"{source}: unsupported object rule code {rule_code}")
if not GUID_RE.fullmatch(object_guid):
raise ValueError(f"{source}: supplier {supplier_index} object {object_index} GUID is invalid")
effective_code = 0 if general_code != 0 else rule_code
rules.append(SupportRule(object_guid, effective_code, OBJECT_RULES[effective_code]))
suppliers.append(
SupplierSupport(
configuration_guid=configuration_guid,
general_mode_code=general_code,
general_mode="editable" if general_code == 0 else "locked",
version=scalar(items[position + 3]),
producer=scalar(items[position + 4]),
name=scalar(items[position + 5]),
declared_object_count=object_count,
rules=tuple(rules),
)
)
position = object_position + object_count * 4 + 2
return {
"source": source,
"compression": decoded["compression"],
"encoding": decoded["encoding"],
"format_marker": format_marker,
"supplier_count": supplier_count,
"suppliers": suppliers,
}
def parse_parent_configurations_file(path: Path) -> dict[str, Any]:
return parse_parent_configurations_bytes(path.read_bytes(), source=path.name)