Files
llm/plugins/1c/connector/repository_control.py
T
2026-08-14 09:40:51 +03:00

1487 lines
79 KiB
Python

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}