from __future__ import annotations import json import hashlib import os import re import sqlite3 import subprocess import tempfile import threading import time import urllib.error import urllib.request import uuid from contextlib import contextmanager from pathlib import Path 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" METHOD_LOCK_REQUEST_CANCEL = "repository.lock.request.cancel" METHOD_LOCK = "repository.lock" METHOD_CONFIRM = "repository.lock.confirm" METHOD_VERIFY = "repository.lock.verify" METHOD_CLOSE = "repository.lock.close" METHOD_UNLOCK = "repository.unlock" METHOD_COMMIT_PLAN = "repository.commit.plan" 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() REPOSITORY_STATE_SCHEMA_VERSION = 2 def external_1c_enabled() -> bool: return str(os.environ.get("ONEC_ADAPTER_ENABLE_EXTERNAL_1C") or "").strip().casefold() in {"1", "true", "yes", "on"} def _base_lock(base_id: str, layer: str) -> threading.Lock: key = f"{base_id}:{layer}" with _BASE_LOCKS_GUARD: return _BASE_LOCKS.setdefault(key, threading.Lock()) def _load_json_map(env_name: str, file_env_name: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: raw = os.environ.get(env_name) path = os.environ.get(file_env_name) if not raw and path: try: raw = Path(path).read_text(encoding="utf-8-sig") except Exception as exc: return None, {"status": "invalid_config", "message": f"Cannot read {file_env_name}: {exc}"} if not raw: return None, { "status": "not_configured", "message": f"Set {env_name} or {file_env_name} with an explicit entry for this base_id.", } try: value = json.loads(raw) except json.JSONDecodeError as exc: return None, {"status": "invalid_config", "message": f"{env_name} is not valid JSON: {exc}"} if not isinstance(value, dict): return None, {"status": "invalid_config", "message": f"{env_name} must be an object keyed by base_id."} return value, 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 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): # A disposable base can explicitly declare that its base layer has # no repository at all. Extensions in such a base are not new # repository layers merely because the adapter has discovered them # after the configuration file was written. Inherit only this # unambiguous no-repository fact; never inherit a manual/automatic # repository policy to an extension. base_layer = base_item["development_layers"].get("base") base_repository = base_layer.get("repository") if isinstance(base_layer, dict) and isinstance(base_layer.get("repository"), dict) else None base_mode = str((base_repository or {}).get("mode") or (base_repository or {}).get("lock_mode") or "").strip().casefold() base_connection = str((base_repository or {}).get("connection_state") or "").strip().casefold() if layer_id != "base" and (base_mode == "none" or base_connection == "not_configured"): item = {"mode": "none", "connection_state": "not_configured", "inherited_from_layer": "base"} else: 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 layer_error: return None, repository_error item = values.get(base_id) if values else None if item is None: return None, {"status": "not_configured", "message": f"No repository configuration for base_id '{base_id}'."} 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, **({"inherited_from_layer": configured["inherited_from_layer"]} if configured.get("inherited_from_layer") else {}), }, None configured["backend"] = str(configured.get("backend") or "direct").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: return None, {"status": "invalid_config", "message": "repository lock_mode must be automatic or manual."} runner = configured.get("runner") if isinstance(configured.get("runner"), dict) else {} configured["runner"] = runner runner_kind = str(runner.get("kind") or "local").strip().casefold() if runner_kind not in {"local", "http"}: return None, {"status": "invalid_config", "message": "repository runner.kind must be local or http."} runner["kind"] = runner_kind required = ("endpoint", "designer_path") if runner_kind == "local" and configured["lock_mode"] == "automatic" else () for key in required: if not str(configured.get(key) or "").strip(): return None, {"status": "invalid_config", "message": f"Repository configuration requires {key}."} if runner_kind == "http" and configured["lock_mode"] == "automatic" and not str(runner.get("url") or "").strip(): return None, {"status": "invalid_config", "message": "repository runner.url is required for runner.kind=http."} infobase = configured.get("infobase") if runner_kind == "local" and configured["lock_mode"] == "automatic" and (not isinstance(infobase, dict) or sum(bool(str(infobase.get(key) or "").strip()) for key in ("file", "server", "name")) != 1): return None, {"status": "invalid_config", "message": "infobase must contain exactly one of file, server, or name for runner.kind=local."} 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 "" 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"), "inherited_from_layer": config.get("inherited_from_layer"), "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"), "bridge_id": config.get("bridge_id") if config.get("backend") == "karman_bridge" else None, "runtime_version": config.get("runtime_version"), "runner_kind": (config.get("runner") or {}).get("kind"), "runner_url": (config.get("runner") or {}).get("url"), "runner_token_env": (config.get("runner") or {}).get("token_env"), "repository_user": str(config.get("repository_user") or ""), "repository_password_env": str(config.get("repository_password_env") or ""), "repository_user_configured": bool(str(config.get("repository_user") or "").strip()), "repository_password_configured": bool(_secret(config, "repository_password")), "infobase_user": str(config.get("infobase_user") or ""), "infobase_password_env": str(config.get("infobase_password_env") or ""), "infobase_user_configured": bool(str(config.get("infobase_user") or "").strip()), "infobase_password_configured": bool(_secret(config, "infobase_password")), } 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"): args = ["/F", str(infobase["file"])] elif infobase.get("server"): args = ["/S", str(infobase["server"])] else: args = ["/IBName", str(infobase["name"])] user = str(config.get("infobase_user") or "").strip() if user: args += ["/N", user] password = _secret(config, "infobase_password") if password: args += ["/P", password] return args def _repository_args(config: dict[str, Any]) -> list[str]: args = ["/ConfigurationRepositoryF", str(config["endpoint"])] user = str(config.get("repository_user") or "").strip() if user: args += ["/ConfigurationRepositoryN", user] password = _secret(config, "repository_password") if password: args += ["/ConfigurationRepositoryP", password] extension = str(config.get("extension") or "").strip() if extension: args += ["-Extension", extension] return args def _safe_excerpt(value: str, config: dict[str, Any], limit: int = 4000) -> str: safe = value for secret in (_secret(config, "repository_password"), _secret(config, "infobase_password")): if secret: safe = safe.replace(secret, "[REDACTED]") return safe[-limit:] def _run_designer(config: dict[str, Any], operation: list[str], timeout_seconds: int) -> dict[str, Any]: started = time.monotonic() with tempfile.TemporaryDirectory(prefix="onec-repository-") as directory: log_path = Path(directory) / "designer.log" args = [str(config["designer_path"]), "DESIGNER"] args += _infobase_args(config) args += ["/DisableStartupMessages", "/DisableStartupDialogs", "/Out", str(log_path)] args += _repository_args(config) args += operation try: completed = subprocess.run(args, capture_output=True, text=True, timeout=timeout_seconds, check=False) except subprocess.TimeoutExpired as exc: return { "status": "timeout", "exit_code": None, "duration_ms": round((time.monotonic() - started) * 1000), "output": _safe_excerpt(str(exc.stdout or "") + str(exc.stderr or ""), config), } except OSError as exc: return {"status": "runner_error", "exit_code": None, "message": str(exc), "duration_ms": round((time.monotonic() - started) * 1000)} log = "" try: log = log_path.read_text(encoding="utf-8-sig", errors="replace") except OSError: pass output = "\n".join(part for part in (completed.stdout, completed.stderr, log) if part) return { "status": "ok" if completed.returncode == 0 else "failed", "exit_code": completed.returncode, "duration_ms": round((time.monotonic() - started) * 1000), "output": _safe_excerpt(output, config), } def activation_debug_probe( base_id: str, config: dict[str, Any], *, layer: str, timeout_seconds: int, request_id: str = "", fingerprint: str = "", ) -> dict[str, Any]: """Probe runner readiness without starting Designer or reading configured credentials.""" runner = config.get("runner") if isinstance(config.get("runner"), dict) else {"kind": "local"} runner_kind = str(runner.get("kind") or "local").strip().casefold() if runner_kind == "http": url = str(runner.get("url") or "").rstrip("/") + "/configuration/activation/debug" request_body = { "base_id": base_id, "layer": layer, "mode": "debug", } if request_id and fingerprint: request_body["request_id"] = request_id request_body["fingerprint"] = fingerprint body = json.dumps( request_body, ensure_ascii=False, ).encode("utf-8") headers = {"Content-Type": "application/json", "Accept": "application/json"} token_env = str(runner.get("token_env") or "").strip() token = os.environ.get(token_env, "") if token_env else "" if token: headers["Authorization"] = f"Bearer {token}" try: with urllib.request.urlopen( urllib.request.Request(url, data=body, headers=headers, method="POST"), timeout=timeout_seconds, ) as response: result = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: try: result = json.loads(exc.read().decode("utf-8")) except (OSError, json.JSONDecodeError, UnicodeDecodeError): result = { "status": "runner_error", "message": f"Activation debug runner returned HTTP {exc.code}.", } return result if isinstance(result, dict) else { "status": "runner_error", "message": f"Activation debug runner returned HTTP {exc.code}.", } except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc: return {"status": "runner_error", "message": str(exc)} return result if isinstance(result, dict) else { "status": "runner_error", "message": "Activation debug runner returned a non-object response.", } infobase = config.get("infobase") if isinstance(config.get("infobase"), dict) else {} selector_configured = ( sum(bool(str(infobase.get(key) or "").strip()) for key in ("file", "server", "name")) == 1 ) designer_path = str(config.get("designer_path") or "").strip() try: designer_available = bool(designer_path and Path(designer_path).is_file()) except OSError: designer_available = False ready = bool(selector_configured and designer_available) debug_acceptance = None if request_id and fingerprint: receipt_source = json.dumps( { "base_id": base_id, "layer": layer, "request_id": request_id, "fingerprint": fingerprint, "mode": "debug", }, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode("utf-8") debug_acceptance = { "accepted": ready, "request_id": request_id, "fingerprint": fingerprint, "receipt": hashlib.sha256(receipt_source).hexdigest() if ready else None, } return { "schema": "onec_configuration_activation_runner_probe.v1", "status": "ready" if ready else "not_ready", "base_id": base_id, "layer": layer, "runner": { "kind": "local", "reachable": True, "designer_path_configured": bool(designer_path), "designer_available": designer_available, "infobase_selector_configured": selector_configured, }, "operation": { "kind": "/UpdateDBCfg" if layer == "base_saved_state" else None, "execution_supported": False, "extension_manual_only": layer in {"all", "extension_saved_state"}, }, "debug_acceptance": debug_acceptance, "execution": { "mode": "debug", "performed": False, "designer_started": False, "active_configuration_changed": False, }, } def _execute_repository( base_id: str, config: dict[str, Any], action: str, timeout_seconds: int, *, objects: list[str] | None = None, comment: str = "", keep_locked: bool = False, ) -> dict[str, Any]: if not external_1c_enabled(): return { "status": "external_1c_disabled", "message": "This adapter version is SQL-only. Use repository.lock.plan and the manual confirmation workflow.", } runner = config.get("runner") if isinstance(config.get("runner"), dict) else {"kind": "local"} if runner.get("kind") == "http": url = str(runner.get("url") or "").rstrip("/") + "/repository/execute" body = json.dumps( {"base_id": base_id, "action": action, "objects": objects or [], "comment": comment, "keep_locked": keep_locked}, ensure_ascii=False, ).encode("utf-8") headers = {"Content-Type": "application/json", "Accept": "application/json"} token_env = str(runner.get("token_env") or "").strip() token = os.environ.get(token_env, "") if token_env else "" if token: headers["Authorization"] = f"Bearer {token}" try: with urllib.request.urlopen(urllib.request.Request(url, data=body, headers=headers, method="POST"), timeout=timeout_seconds) as response: result = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: try: result = json.loads(exc.read().decode("utf-8")) except (OSError, json.JSONDecodeError, UnicodeDecodeError): result = {"status": "runner_error", "message": f"Repository runner returned HTTP {exc.code}."} return result if isinstance(result, dict) else {"status": "runner_error", "message": f"Repository runner returned HTTP {exc.code}."} except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc: return {"status": "runner_error", "message": str(exc)} return result if isinstance(result, dict) else {"status": "runner_error", "message": "Repository runner returned a non-object response."} if action == "report": with tempfile.TemporaryDirectory(prefix="onec-repository-report-") as directory: report = Path(directory) / "report.txt" return _run_designer(config, ["/ConfigurationRepositoryReport", str(report), "-NBegin", "-1", "-ReportFormat", "txt"], timeout_seconds) with tempfile.TemporaryDirectory(prefix="onec-repository-objects-") as directory: objects_path = Path(directory) / "objects.txt" objects_path.write_text("\n".join(objects or []) + "\n", encoding="utf-8") if action == "lock": operation = ["/ConfigurationRepositoryLock", "-Objects", str(objects_path)] elif action == "unlock": operation = ["/ConfigurationRepositoryUnlock", "-Objects", str(objects_path)] elif action == "commit": operation = ["/ConfigurationRepositoryCommit", "-Objects", str(objects_path), "-Comment", comment] if keep_locked: operation.append("-KeepLocked") else: return {"status": "runner_error", "message": f"Unsupported repository action: {action}"} return _run_designer(config, operation, timeout_seconds) _CHILD_MARKERS = re.compile( r"\.(?:Реквизит|Attribute|ТабличнаяЧасть|TabularSection|Измерение|Dimension|Ресурс|Resource)\.", re.IGNORECASE, ) def development_object(ref: str) -> str: value = ref.strip().strip(".") match = _CHILD_MARKERS.search(value) if match: return value[: match.start()] for marker in (".МодульОбъекта", ".ObjectModule", ".МодульМенеджера", ".ManagerModule"): if value.casefold().endswith(marker.casefold()): return value[: -len(marker)] return value def _requested_objects(payload: dict[str, Any]) -> tuple[list[str] | None, dict[str, Any] | None]: raw = payload.get("objects") if raw is None: raw = [payload.get("object") or payload.get("ref") or payload.get("path")] if not isinstance(raw, list) or not raw: return None, {"status": "invalid_argument", "argument": "objects", "message": "Pass object/ref/path or a non-empty objects array."} values: list[str] = [] for index, item in enumerate(raw): if not isinstance(item, str) or not item.strip(): return None, {"status": "invalid_argument", "argument": f"objects[{index}]", "message": "Repository object must be a non-empty public 1C reference."} resolved = development_object(item) if resolved not in values: values.append(resolved) return values, None def lock_plan(payload: dict[str, Any]) -> dict[str, Any]: objects, error = _requested_objects(payload) if error: return {"schema": "onec_repository_lock_plan.v1", "method": METHOD_LOCK_PLAN, **error} operation = str(payload.get("operation") or "modify").strip().casefold() warnings: list[dict[str, str]] = [] if operation in {"add", "delete", "rename"}: warnings.append({"code": "parent_scope_requires_confirmation", "message": "Structural operations can require the parent/root and referenced objects; confirm the complete set before lock/apply."}) result = { "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: 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("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_LOCK_REQUEST if not external_1c_enabled() else METHOD_CONFIRM result["user_action"] = { "action": "confirm_lock_in_configurator", "base_id": base_id, "objects": objects, "message": "SQL информационной базы не показывает нативное состояние захвата. Убедитесь в Конфигураторе, что перечисленные объекты уже захвачены указанным пользователем хранилища, и явно подтвердите тот же список через repository.lock.confirm.", } else: result["workflow"] = "automatic" result["next_method"] = METHOD_LOCK return result def create_lock_request(payload: dict[str, Any]) -> dict[str, Any]: base_id = str(payload.get("base_id") or "").strip() 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_transaction() as state: state.setdefault("requests", {})[request_id] = { "base_id": base_id, "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"]) return { "schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST, "base_id": base_id, "layer_id": layer_id, "status": "pending_user_lock", "request_id": request_id, "objects": plan["lock_objects"], "automatically_locked": False, "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), } def lock_request_status(payload: dict[str, Any]) -> dict[str, Any]: request_id = str(payload.get("request_id") or "").strip() 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} result = { "schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": request.get("status"), "request_id": request_id, # Surface the manual-confirmation scope at top level. Requiring # callers to inspect an opaque persisted request made a pending lock # look context-free and encouraged unsafe confirmation guesses. "base_id": request.get("base_id"), "layer_id": request.get("layer_id") or request.get("layer"), "objects": list(request.get("objects") or []), "repository_user": request.get("repository_user") or None, "native_lock_state": "unknown", "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]: request_id = str(payload.get("request_id") or "").strip() if payload.get("confirm_cancel") is not True: return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "confirmation_required", "request_id": request_id} with _state_transaction() as state: request = (state.get("requests") or {}).get(request_id) if not isinstance(request, dict): return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "not_found", "request_id": request_id} if request.get("status") != "pending_user_lock": return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "blocked", "error": "lock_request_not_pending", "request_id": request_id} request["status"] = "cancelled" request["cancelled_at"] = time.time() _audit(state, "lock_request_cancelled", request_id=request_id, base_id=request.get("base_id"), objects=request.get("objects")) return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "cancelled", "request_id": request_id} def _state_path() -> Path: """Legacy JSON path used only for one-time migration to local SQLite.""" return Path(os.environ.get("ONEC_REPOSITORY_STATE_FILE") or "/data/onec-repository-locks.json") def _state_db_path() -> Path: """Adapter-local state database; never points at a configured 1C database.""" configured = os.environ.get("ONEC_ADAPTER_STATE_DB") or os.environ.get("ONEC_ADAPTER_CACHE_DB") if configured: return Path(configured) legacy_override = os.environ.get("ONEC_REPOSITORY_STATE_FILE") if legacy_override: return Path(legacy_override).with_suffix(".sqlite") return Path("/data/adapter-cache.sqlite") def _empty_state() -> dict[str, Any]: return {"sessions": {}, "requests": {}, "audit": []} def _read_legacy_state() -> dict[str, Any]: try: value = json.loads(_state_path().read_text(encoding="utf-8-sig")) return value if isinstance(value, dict) else _empty_state() except (OSError, json.JSONDecodeError): return _empty_state() def _state_connection() -> sqlite3.Connection: path = _state_db_path() path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path, timeout=30) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA synchronous=NORMAL") conn.execute("PRAGMA busy_timeout=30000") conn.execute( """ CREATE TABLE IF NOT EXISTS adapter_state_meta ( key TEXT PRIMARY KEY, value TEXT, updated_at REAL NOT NULL ) """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS repository_lock_requests ( request_id TEXT PRIMARY KEY, base_id TEXT NOT NULL, layer_id TEXT NOT NULL, status TEXT NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL, payload_json TEXT NOT NULL ) """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS repository_lock_sessions ( lock_session_id TEXT PRIMARY KEY, base_id TEXT NOT NULL, layer_id TEXT NOT NULL, status TEXT NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL, payload_json TEXT NOT NULL ) """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS repository_lock_events ( event_id TEXT PRIMARY KEY, event TEXT NOT NULL, occurred_at REAL NOT NULL, base_id TEXT, request_id TEXT, lock_session_id TEXT, details_json TEXT NOT NULL ) """ ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_repository_lock_requests_base_status " "ON repository_lock_requests(base_id, status, created_at)" ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_repository_lock_sessions_base_status " "ON repository_lock_sessions(base_id, status, created_at)" ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_repository_lock_events_base_time " "ON repository_lock_events(base_id, occurred_at)" ) conn.execute( """ INSERT INTO adapter_state_meta(key, value, updated_at) VALUES('adapter_state_schema_version', ?, ?) ON CONFLICT(key) DO UPDATE SET value=CASE WHEN CAST(adapter_state_meta.value AS INTEGER) < CAST(excluded.value AS INTEGER) THEN excluded.value ELSE adapter_state_meta.value END, updated_at=excluded.updated_at """, (str(REPOSITORY_STATE_SCHEMA_VERSION), time.time()), ) migration = conn.execute( "SELECT value FROM adapter_state_meta WHERE key='legacy_repository_state_imported'" ).fetchone() if not migration: legacy = _read_legacy_state() _sync_state_to_connection(conn, legacy) conn.execute( "INSERT INTO adapter_state_meta(key, value, updated_at) VALUES(?, ?, ?)", ("legacy_repository_state_imported", "1", time.time()), ) conn.commit() return conn def _event_id(row: dict[str, Any], index: int) -> str: explicit = str(row.get("event_id") or "").strip() if explicit: return explicit source = json.dumps( { "index": index, "event": row.get("event"), "time": row.get("time"), "request_id": row.get("request_id"), "lock_session_id": row.get("lock_session_id"), "base_id": row.get("base_id"), }, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ) return "legacy-" + hashlib.sha1(source.encode("utf-8")).hexdigest() def _load_state_from_connection(conn: sqlite3.Connection) -> dict[str, Any]: state = _empty_state() for row in conn.execute("SELECT request_id, payload_json FROM repository_lock_requests"): try: payload = json.loads(row["payload_json"]) except (TypeError, json.JSONDecodeError): continue if isinstance(payload, dict): state["requests"][str(row["request_id"])] = payload for row in conn.execute("SELECT lock_session_id, payload_json FROM repository_lock_sessions"): try: payload = json.loads(row["payload_json"]) except (TypeError, json.JSONDecodeError): continue if isinstance(payload, dict): state["sessions"][str(row["lock_session_id"])] = payload for row in conn.execute( "SELECT event_id, event, occurred_at, details_json FROM repository_lock_events " "ORDER BY occurred_at, event_id" ): try: payload = json.loads(row["details_json"]) except (TypeError, json.JSONDecodeError): payload = {} if not isinstance(payload, dict): payload = {} payload.setdefault("event_id", str(row["event_id"])) payload.setdefault("event", str(row["event"])) payload.setdefault("time", float(row["occurred_at"])) state["audit"].append(payload) return state def _sync_state_to_connection(conn: sqlite3.Connection, value: dict[str, Any]) -> None: now = time.time() for request_id, raw in (value.get("requests") or {}).items(): if not isinstance(raw, dict): continue request = dict(raw) created_at = float(request.get("created_at") or now) updated_at = float( request.get("cancelled_at") or request.get("confirmed_at") or request.get("closed_at") or request.get("expired_at") or created_at ) conn.execute( """ INSERT INTO repository_lock_requests( request_id, base_id, layer_id, status, created_at, updated_at, payload_json ) VALUES(?, ?, ?, ?, ?, ?, ?) ON CONFLICT(request_id) DO UPDATE SET base_id=excluded.base_id, layer_id=excluded.layer_id, status=excluded.status, updated_at=excluded.updated_at, payload_json=excluded.payload_json """, ( str(request_id), str(request.get("base_id") or ""), str(request.get("layer_id") or request.get("layer") or "base"), str(request.get("status") or "unknown"), created_at, updated_at, json.dumps(request, ensure_ascii=False, separators=(",", ":")), ), ) for session_id, raw in (value.get("sessions") or {}).items(): if not isinstance(raw, dict): continue session = dict(raw) created_at = float(session.get("created_at") or now) updated_at = float( session.get("committed_at") or session.get("released_at") or session.get("closed_at") or session.get("expired_at") or created_at ) conn.execute( """ INSERT INTO repository_lock_sessions( lock_session_id, base_id, layer_id, status, created_at, updated_at, payload_json ) VALUES(?, ?, ?, ?, ?, ?, ?) ON CONFLICT(lock_session_id) DO UPDATE SET base_id=excluded.base_id, layer_id=excluded.layer_id, status=excluded.status, updated_at=excluded.updated_at, payload_json=excluded.payload_json """, ( str(session_id), str(session.get("base_id") or ""), str(session.get("layer_id") or session.get("layer") or "base"), str(session.get("status") or "unknown"), created_at, updated_at, json.dumps(session, ensure_ascii=False, separators=(",", ":")), ), ) audit = [row for row in (value.get("audit") or []) if isinstance(row, dict)][-5000:] for index, raw in enumerate(audit): event = dict(raw) event_id = _event_id(event, index) event["event_id"] = event_id occurred_at = float(event.get("time") or now) conn.execute( """ INSERT OR IGNORE INTO repository_lock_events( event_id, event, occurred_at, base_id, request_id, lock_session_id, details_json ) VALUES(?, ?, ?, ?, ?, ?, ?) """, ( event_id, str(event.get("event") or "unknown"), occurred_at, str(event.get("base_id") or "") or None, str(event.get("request_id") or "") or None, str(event.get("lock_session_id") or "") or None, json.dumps(event, ensure_ascii=False, separators=(",", ":")), ), ) def _expire_state(state: dict[str, Any]) -> bool: changed = False now = time.time() request_ttl = max(60, int(os.environ.get("ONEC_REPOSITORY_REQUEST_TTL_SECONDS") or 86400)) session_ttl = max(60, int(os.environ.get("ONEC_REPOSITORY_CONFIRMATION_TTL_SECONDS") or 7200)) for request in (state.get("requests") or {}).values(): if isinstance(request, dict) and request.get("status") == "pending_user_lock" and now - float(request.get("created_at") if request.get("created_at") is not None else now) > request_ttl: request["status"] = "expired" request["expired_at"] = now changed = True for session in (state.get("sessions") or {}).values(): if isinstance(session, dict) and session.get("status") == "manual_confirmed" and now - float(session.get("created_at") if session.get("created_at") is not None else now) > session_ttl: session["status"] = "expired" session["expired_at"] = now changed = True return changed def _read_state() -> dict[str, Any]: with _STATE_LOCK: with _state_connection() as conn: state = _load_state_from_connection(conn) if _expire_state(state): conn.execute("BEGIN IMMEDIATE") _sync_state_to_connection(conn, state) conn.commit() return state def _write_state(value: dict[str, Any]) -> None: with _STATE_LOCK: with _state_connection() as conn: conn.execute("BEGIN IMMEDIATE") _sync_state_to_connection(conn, value) conn.commit() @contextmanager def _state_transaction() -> Any: """Serialize a repository state mutation across adapter processes.""" with _STATE_LOCK: conn = _state_connection() try: conn.execute("BEGIN IMMEDIATE") state = _load_state_from_connection(conn) _expire_state(state) yield state _sync_state_to_connection(conn, state) conn.commit() except Exception: conn.rollback() raise finally: conn.close() def _audit(state: dict[str, Any], event: str, **details: Any) -> None: rows = state.setdefault("audit", []) rows.append({"event_id": "revt-" + uuid.uuid4().hex, "event": event, "time": time.time(), **details}) if len(rows) > 5000: del rows[:-5000] def admin_state(base_id: str = "") -> dict[str, Any]: state = _read_state() requests = [{"request_id": key, **row} for key, row in (state.get("requests") or {}).items() if isinstance(row, dict) and (not base_id or row.get("base_id") == base_id)] sessions = [{"lock_session_id": key, **row} for key, row in (state.get("sessions") or {}).items() if isinstance(row, dict) and (not base_id or row.get("base_id") == base_id)] audit = [row for row in (state.get("audit") or []) if isinstance(row, dict) and (not base_id or row.get("base_id") == base_id)] requests.sort(key=lambda row: float(row.get("created_at") or 0), reverse=True) sessions.sort(key=lambda row: float(row.get("created_at") or 0), reverse=True) audit.sort(key=lambda row: float(row.get("time") or 0), reverse=True) return {"schema": "onec_repository_admin_state.v1", "base_id": base_id or None, "requests": requests, "sessions": sessions, "audit": audit[:200], "counts": {"requests": len(requests), "sessions": len(sessions), "audit": len(audit)}} def status(payload: dict[str, Any]) -> dict[str, Any]: base_id = str(payload.get("base_id") or "").strip() 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, "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 timeout_seconds = int(payload.get("timeout_seconds") or 60) executed = _execute_repository(base_id, config, "report", timeout_seconds) result["probe"] = executed result["available"] = executed.get("status") == "ok" result["status"] = "ready" if result["available"] else "blocked_repository_unavailable" return result def lock(payload: dict[str, Any]) -> dict[str, Any]: base_id = str(payload.get("base_id") or "").strip() 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 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, } plan = lock_plan(payload) if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True: plan["status"] = "ready" plan["scope_confirmed"] = True if plan.get("status") != "ready": return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "plan": plan} if not payload.get("allow_repository_lock") is True: return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "error": "explicit_repository_lock_required", "plan": plan} layer = str(config.get("layer") or "base") with _base_lock(base_id, layer): executed = _execute_repository(base_id, config, "lock", int(payload.get("timeout_seconds") or 120), objects=plan["lock_objects"]) if executed.get("status") != "ok": return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "blocked", "error": "repository_lock_failed", "plan": plan, "execution": executed} session_id = "rlock-" + uuid.uuid4().hex with _state_transaction() as state: sessions = state.setdefault("sessions", {}) 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"} 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() 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 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} state = _read_state() request_id = str(payload.get("request_id") or "").strip() request = (state.get("requests") or {}).get(request_id) if request_id else None if request_id and (not isinstance(request, dict) or request.get("base_id") != base_id): return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "error": "lock_request_not_found", "request_id": request_id} if isinstance(request, dict) and request.get("status") != "pending_user_lock": 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} 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 with _state_transaction() as current_state: current_request = (current_state.get("requests") or {}).get(request_id) if request_id else None if request_id and ( not isinstance(current_request, dict) or current_request.get("base_id") != base_id or current_request.get("status") != "pending_user_lock" ): 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, } current_state.setdefault("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": "manual_confirmed", "verification": "user_confirmation_only", "automatically_verified": False, "repository_user": confirmed_repository_user, **({"request_id": request_id} if request_id else {}), } if isinstance(current_request, dict): current_request["status"] = "confirmed_by_user" current_request["confirmed_at"] = time.time() current_request["lock_session_id"] = session_id _audit(current_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) return { "schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "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 хранилища.", } def verify(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) if not isinstance(session, dict): return {"schema": "onec_repository_lock_verify.v1", "method": METHOD_VERIFY, "status": "not_found", "lock_session_id": session_id} verify_status = "owned_by_adapter" if session.get("status") == "acquired" else ("manual_confirmation_unverified" if session.get("status") == "manual_confirmed" else session.get("status")) return {"schema": "onec_repository_lock_verify.v1", "method": METHOD_VERIFY, "status": verify_status, "lock_session_id": session_id, "session": session} def close_manual_lock(payload: dict[str, Any]) -> dict[str, Any]: session_id = str(payload.get("lock_session_id") or "").strip() if payload.get("user_confirmed_released") is not True: return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "confirmation_required", "lock_session_id": session_id} with _state_transaction() as state: session = (state.get("sessions") or {}).get(session_id) if not isinstance(session, dict): return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "not_found", "lock_session_id": session_id} if session.get("status") not in {"manual_confirmed", "closed"}: return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "blocked", "error": "manual_confirmation_not_active", "lock_session_id": session_id} already_closed = session.get("status") == "closed" session["status"] = "closed" closed_at = float(session.get("closed_at") or time.time()) session["closed_at"] = closed_at request_id = str(session.get("request_id") or "").strip() request = (state.get("requests") or {}).get(request_id) if request_id else None if not isinstance(request, dict): request_id = next( ( str(candidate_id) for candidate_id, candidate in (state.get("requests") or {}).items() if isinstance(candidate, dict) and candidate.get("lock_session_id") == session_id ), "", ) request = (state.get("requests") or {}).get(request_id) if request_id else None if isinstance(request, dict): request["status"] = "closed" request["closed_at"] = closed_at if not already_closed: _audit(state, "manual_lock_closed", request_id=request_id or None, lock_session_id=session_id, base_id=session.get("base_id"), objects=session.get("objects")) return { "schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "closed", "lock_session_id": session_id, "request_id": request_id or None, } def write_gate(payload: dict[str, Any]) -> dict[str, Any]: base_id = str(payload.get("base_id") or "").strip() 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", "layer_id": layer_id} if 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"), "layer_id": layer_id, "next_method": METHOD_LOCK_PLAN, } session = (_read_state().get("sessions") or {}).get(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 ( payload.get("repository_object"), target.get("repository_object"), target.get("canonical_path"), payload.get("canonical_path"), target.get("path"), payload.get("path"), payload.get("ref"), payload.get("object"), ): if isinstance(value, str) and value.strip(): requested = development_object(value) break if not requested: kind = str(payload.get("object_type") or payload.get("kind") or target.get("object_type") or target.get("kind") or "").strip() name = str(payload.get("object_name") or payload.get("name") or target.get("object_name") or target.get("name") or "").strip() if kind and name: requested = development_object(f"{kind}.{name}") if not requested: return { "required": True, "allowed": False, "status": "blocked_repository_scope_unresolved", "lock_session_id": session_id, "message": "Pass repository_object with the public 1C development-object reference for this low-level write route.", } locked = [str(item) for item in session.get("objects") or []] if requested.casefold() not in {item.casefold() for item in locked}: return { "required": True, "allowed": False, "status": "blocked_repository_scope_mismatch", "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"), "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): # See repository_config(): an explicit repository-less base is the # disposable-test profile. It applies to newly discovered extensions # as well, so a missing per-extension policy cannot turn a permitted # test write into a false "unknown support" block. repository, repository_error = repository_config(base_id, layer_id) if repository_error is None and isinstance(repository, dict) and repository.get("mode") == "none": return { "required": False, "allowed": True, "status": "not_on_support_inherited_no_repository", "layer_id": layer_id, "inherited_from_layer": repository.get("inherited_from_layer"), } 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) if not isinstance(session, dict): return {"schema": "onec_repository_commit_plan.v1", "method": METHOD_COMMIT_PLAN, "status": "not_found", "lock_session_id": session_id} comment = str(payload.get("comment") or "").strip() problems: list[dict[str, str]] = [] if session.get("status") != "acquired": problems.append({"code": "lock_session_not_acquired", "message": "Commit requires an active adapter lock session."}) if not comment: problems.append({"code": "commit_comment_required", "message": "A non-empty repository version comment is required."}) return { "schema": "onec_repository_commit_plan.v1", "method": METHOD_COMMIT_PLAN, "status": "ready" if not problems else "blocked", "allowed": not problems, "lock_session_id": session_id, "base_id": session.get("base_id"), "objects": session.get("objects") or [], "comment": comment, "problems": problems, } def commit(payload: dict[str, Any]) -> dict[str, Any]: plan = commit_plan(payload) session_id = str(payload.get("lock_session_id") or "").strip() if not plan.get("allowed"): return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "lock_session_id": session_id, "plan": plan} if payload.get("allow_repository_commit") is not True: return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "explicit_repository_commit_required", "lock_session_id": session_id, "plan": plan} 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, 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")): executed = _execute_repository(base_id, config, "commit", int(payload.get("timeout_seconds") or 180), objects=[str(item) for item in session.get("objects") or []], comment=str(plan["comment"]), keep_locked=payload.get("keep_locked") is True) if executed.get("status") != "ok": return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "repository_commit_failed", "lock_session_id": session_id, "execution": executed} with _state_transaction() as current_state: current_session = (current_state.get("sessions") or {}).get(session_id) if not isinstance(current_session, dict) or current_session.get("status") != "acquired": return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": "blocked", "error": "lock_session_not_acquired", "lock_session_id": session_id} current_session["status"] = "acquired" if payload.get("keep_locked") is True else "committed" current_session["committed_at"] = time.time() current_session["commit_comment"] = str(plan["comment"]) final_status = str(current_session["status"]) committed_objects = current_session.get("objects") return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": final_status, "lock_session_id": session_id, "committed": committed_objects, "keep_locked": payload.get("keep_locked") is True, "execution": executed} def unlock(payload: dict[str, Any]) -> dict[str, Any]: session_id = str(payload.get("lock_session_id") or "").strip() state = _read_state() session = (state.get("sessions") or {}).get(session_id) if not isinstance(session, dict): return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "not_found", "lock_session_id": session_id} if session.get("status") != "acquired": return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": str(session.get("status")), "lock_session_id": session_id} 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, 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")): executed = _execute_repository(base_id, config, "unlock", int(payload.get("timeout_seconds") or 120), objects=[str(item) for item in session.get("objects") or []]) if executed.get("status") != "ok": return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "repository_unlock_failed", "lock_session_id": session_id, "execution": executed} with _state_transaction() as current_state: current_session = (current_state.get("sessions") or {}).get(session_id) if not isinstance(current_session, dict) or current_session.get("status") != "acquired": return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "blocked", "error": "lock_session_not_acquired", "lock_session_id": session_id} current_session["status"] = "released" current_session["released_at"] = time.time() released_objects = current_session.get("objects") return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "released", "lock_session_id": session_id, "released": released_objects, "execution": executed} def call(method: str, payload: dict[str, Any]) -> dict[str, Any]: if method == METHOD_STATUS: return status(payload) if method == METHOD_LOCK_PLAN: return lock_plan(payload) if method == METHOD_LOCK_REQUEST: return create_lock_request(payload) if method == METHOD_LOCK_REQUEST_STATUS: return lock_request_status(payload) if method == METHOD_LOCK_REQUEST_CANCEL: return cancel_lock_request(payload) if method == METHOD_LOCK: return lock(payload) if method == METHOD_CONFIRM: return confirm_manual_lock(payload) if method == METHOD_VERIFY: return verify(payload) if method == METHOD_CLOSE: return close_manual_lock(payload) if method == METHOD_UNLOCK: return unlock(payload) if method == METHOD_COMMIT_PLAN: return commit_plan(payload) if method == METHOD_COMMIT: return commit(payload) return {"status": "method_not_found", "method": method}