Files
llm/plugins/1c/connector/repository_control.py
T

723 lines
40 KiB
Python

from __future__ import annotations
import json
import os
import re
import subprocess
import tempfile
import threading
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
from typing import Any
METHOD_STATUS = "repository.status"
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"}
_BASE_LOCKS: dict[str, threading.Lock] = {}
_BASE_LOCKS_GUARD = threading.Lock()
_STATE_LOCK = threading.RLock()
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 repository_config(base_id: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
values, error = _load_json_map("ONEC_SQL_BASES_JSON", "ONEC_SQL_BASES_JSON_FILE")
base_item = values.get(base_id) if values and isinstance(values.get(base_id), dict) else None
item = base_item.get("repository") if isinstance(base_item, dict) else None
if item is None:
values, repository_error = _load_json_map("ONEC_REPOSITORY_BASES_JSON", "ONEC_REPOSITORY_BASES_JSON_FILE")
if repository_error and 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["backend"] = str(configured.get("backend") or "direct").strip().casefold()
configured["layer"] = str(configured.get("layer") or "base").strip().casefold()
configured["lock_mode"] = str(configured.get("lock_mode") or "automatic").strip().casefold()
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 _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 {
"backend": config.get("backend"),
"layer": config.get("layer"),
"lock_mode": config.get("lock_mode"),
"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 _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 _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",
"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,
}
base_id = str(payload.get("base_id") or "").strip()
if base_id:
config, config_error = repository_config(base_id)
if config_error:
result["repository_problem"] = config_error
elif config.get("lock_mode") == "manual":
result["workflow"] = "manual"
result["next_method"] = METHOD_CONFIRM
result["user_action"] = {
"action": "lock_in_configurator",
"base_id": base_id,
"objects": objects,
"message": "Захватите перечисленные объекты в Конфигураторе, затем явно подтвердите тот же список через 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()
config, error = repository_config(base_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("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}
request_id = "rreq-" + uuid.uuid4().hex
with _STATE_LOCK:
state = _read_state()
state.setdefault("requests", {})[request_id] = {
"base_id": base_id,
"layer": str(config.get("layer") or "base"),
"backend": config.get("backend"),
"operation": plan.get("operation"),
"objects": plan["lock_objects"],
"created_at": time.time(),
"status": "pending_user_lock",
"execution": "manual",
}
_audit(state, "lock_request_created", request_id=request_id, base_id=base_id, objects=plan["lock_objects"])
_write_state(state)
return {
"schema": "onec_repository_lock_request.v1", "method": METHOD_LOCK_REQUEST,
"base_id": base_id, "status": "pending_user_lock", "request_id": request_id,
"objects": plan["lock_objects"], "automatically_locked": False,
"user_action": "Захватите перечисленные объекты в Конфигураторе и подтвердите заявку через repository.lock.confirm.",
"next_method": METHOD_CONFIRM,
}
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}
return {"schema": "onec_repository_lock_request_status.v1", "method": METHOD_LOCK_REQUEST_STATUS, "status": request.get("status"), "request_id": request_id, "request": request}
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_LOCK:
state = _read_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"))
_write_state(state)
return {"schema": "onec_repository_lock_request_cancel.v1", "method": METHOD_LOCK_REQUEST_CANCEL, "status": "cancelled", "request_id": request_id}
def _state_path() -> Path:
return Path(os.environ.get("ONEC_REPOSITORY_STATE_FILE") or "/data/onec-repository-locks.json")
def _read_state() -> dict[str, Any]:
try:
value = json.loads(_state_path().read_text(encoding="utf-8-sig"))
state = value if isinstance(value, dict) else {"sessions": {}, "requests": {}, "audit": []}
except (OSError, json.JSONDecodeError):
state = {"sessions": {}, "requests": {}, "audit": []}
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
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
return state
def _write_state(value: dict[str, Any]) -> None:
path = _state_path()
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(path.name + f".{uuid.uuid4().hex}.tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(temporary, path)
def _audit(state: dict[str, Any], event: str, **details: Any) -> None:
rows = state.setdefault("audit", [])
rows.append({"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()
config, error = repository_config(base_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,
"status": "configured", "connected": True, "available": None, "repository": _public_config(config),
}
if not bool(payload.get("probe")):
return result
if not external_1c_enabled():
result["status"] = "sql_only"
result["available"] = None
result["probe"] = {"status": "not_supported", "message": "External 1C access is disabled in this SQL-only adapter version."}
return result
if config.get("lock_mode") == "manual":
result["status"] = "manual_workflow"
result["available"] = None
result["probe"] = {"status": "not_applicable", "message": "Manual lock mode does not require Designer or a repository runner. Use repository.lock.plan."}
return result
timeout_seconds = int(payload.get("timeout_seconds") or 60)
executed = _execute_repository(base_id, config, "report", timeout_seconds)
result["probe"] = executed
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()
config, error = repository_config(base_id)
if error:
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, **error}
if config.get("lock_mode") == "manual":
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
state = _read_state()
sessions = state.setdefault("sessions", {})
sessions[session_id] = {"base_id": base_id, "layer": layer, "backend": config.get("backend"), "objects": plan["lock_objects"], "created_at": time.time(), "status": "acquired"}
_write_state(state)
return {"schema": "onec_repository_lock.v1", "method": METHOD_LOCK, "base_id": base_id, "status": "acquired", "lock_session_id": session_id, "acquired": plan["lock_objects"], "backend": config.get("backend"), "execution": executed}
def confirm_manual_lock(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
if error:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, **error}
if config.get("lock_mode") != "manual":
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):
effective_payload["objects"] = [str(item) for item in request.get("objects") or []]
plan = lock_plan(effective_payload)
if plan.get("status") == "needs_confirmation" and payload.get("confirm_repository_scope") is True:
plan["status"] = "ready"
if plan.get("status") != "ready":
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "blocked", "plan": plan}
if payload.get("user_confirmed_locked") is not True:
return {"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id, "status": "confirmation_required", "plan": plan}
session_id = "rlock-" + uuid.uuid4().hex
state.setdefault("sessions", {})[session_id] = {
"base_id": base_id, "layer": str(config.get("layer") or "base"), "backend": config.get("backend"),
"objects": plan["lock_objects"], "created_at": time.time(), "status": "manual_confirmed",
"verification": "user_confirmation_only", "automatically_verified": False,
}
if isinstance(request, dict):
request["status"] = "confirmed_by_user"
request["confirmed_at"] = time.time()
request["lock_session_id"] = session_id
_audit(state, "manual_lock_confirmed", request_id=request_id or None, lock_session_id=session_id, base_id=base_id, objects=plan["lock_objects"])
_write_state(state)
return {
"schema": "onec_repository_manual_lock.v1", "method": METHOD_CONFIRM, "base_id": base_id,
"status": "manual_confirmed", "lock_session_id": session_id, "request_id": request_id or None, "objects": plan["lock_objects"],
"automatically_verified": False,
"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_LOCK:
state = _read_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") != "manual_confirmed":
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "blocked", "error": "manual_confirmation_not_active", "lock_session_id": session_id}
session["status"] = "closed"
session["closed_at"] = time.time()
_audit(state, "manual_lock_closed", lock_session_id=session_id, base_id=session.get("base_id"), objects=session.get("objects"))
_write_state(state)
return {"schema": "onec_repository_lock_close.v1", "method": METHOD_CLOSE, "status": "closed", "lock_session_id": session_id}
def write_gate(payload: dict[str, Any]) -> dict[str, Any]:
base_id = str(payload.get("base_id") or "").strip()
config, error = repository_config(base_id)
if error and error.get("status") == "not_configured":
return {"required": False, "allowed": True, "status": "not_configured"}
if error:
return {"required": True, "allowed": False, "status": "blocked_repository_configuration", "problem": error}
session_id = str(payload.get("lock_session_id") or "").strip()
if not session_id:
return {
"required": True, "allowed": False, "status": "needs_repository_lock",
"backend": config.get("backend"), "next_method": METHOD_LOCK_PLAN,
}
session = (_read_state().get("sessions") or {}).get(session_id)
if not isinstance(session, dict) or session.get("base_id") != base_id or session.get("status") not in {"acquired", "manual_confirmed"}:
return {"required": True, "allowed": False, "status": "blocked_repository_lock_session", "lock_session_id": session_id}
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",
"lock_session_id": session_id, "requested_object": requested, "locked_objects": locked,
}
return {
"required": True, "allowed": True, "status": "ready", "backend": config.get("backend"),
"lock_session_id": session_id, "requested_object": requested, "objects": locked,
"verification": "automatic" if session.get("status") == "acquired" else "user_confirmation_only",
}
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)
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}
session["status"] = "acquired" if payload.get("keep_locked") is True else "committed"
session["committed_at"] = time.time()
session["commit_comment"] = str(plan["comment"])
_write_state(state)
return {"schema": "onec_repository_commit.v1", "method": METHOD_COMMIT, "status": session["status"], "lock_session_id": session_id, "committed": session.get("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)
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}
session["status"] = "released"
session["released_at"] = time.time()
_write_state(state)
return {"schema": "onec_repository_unlock.v1", "method": METHOD_UNLOCK, "status": "released", "lock_session_id": session_id, "released": session.get("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}