Files
llm/scripts/plan_1c_saved_state_copy.py
T

336 lines
15 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
import urllib.request
from pathlib import Path
from typing import Any
DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011"
DISCOVERY_KINDS = ("Catalog", "Document", "DataProcessor", "Report")
SOURCE_TABLES = {"Config", "ConfigCAS"}
TARGET_TABLES = {"ConfigSave", "ConfigCASSave"}
SOURCE_BY_TARGET = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"}
def rpc(base_url: str, method: str, payload: dict[str, Any], timeout: float) -> dict[str, Any]:
body = json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(
base_url.rstrip("/") + "/rpc",
data=body,
method="POST",
headers={"Content-Type": "application/json; charset=utf-8"},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
data = json.loads(response.read().decode("utf-8"))
if not isinstance(data, dict):
raise RuntimeError(f"{method} returned non-object response: {data!r}")
return data
def sql_literal(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def selector_from_args(args: argparse.Namespace) -> dict[str, Any]:
selector: dict[str, Any] = {"base_id": args.base_id, "include_storage": True, "table": SOURCE_BY_TARGET[args.target_table]}
for key in ("ref", "kind", "name", "guid"):
value = getattr(args, key)
if value:
selector[key] = value
return selector
def discover_object(base_url: str, base_id: str, source_table: str, timeout: float) -> dict[str, Any]:
fallback: dict[str, Any] | None = None
best: tuple[int, dict[str, Any]] | None = None
for kind in DISCOVERY_KINDS:
listed = rpc(
base_url,
"metadata.objects.list",
{"base_id": base_id, "kind": kind, "table": source_table, "limit": 20, "include_storage": True},
timeout,
)
for item in listed.get("objects") or []:
if not isinstance(item, dict) or not item.get("guid") or not item.get("name"):
continue
if fallback is None:
fallback = item
selector = object_selector(base_id, item, default_table=source_table)
try:
forms = rpc(base_url, "metadata.object.forms", selector, timeout)
modules = rpc(base_url, "metadata.object.modules", selector, timeout)
except Exception:
continue
form_count = ((forms.get("counts") or {}).get("forms") or 0) if isinstance(forms.get("counts"), dict) else 0
module_count = ((modules.get("counts") or {}).get("modules") or 0) if isinstance(modules.get("counts"), dict) else 0
score = (10 if form_count > 0 else 0) + (10 if module_count > 0 else 0) + min(int(form_count), 5) + min(int(module_count), 5)
if best is None or score > best[0]:
best = (score, item)
if form_count > 0 and module_count > 0:
return item
if best is not None and best[0] > 0:
return best[1]
if fallback is not None:
return fallback
raise RuntimeError(f"No {source_table} metadata object candidate found for saved-state copy planning.")
def resolve_object(base_url: str, selector: dict[str, Any], source_table: str, timeout: float) -> dict[str, Any]:
if not any(selector.get(key) for key in ("ref", "kind", "name", "guid")):
return discover_object(base_url, str(selector["base_id"]), source_table, timeout)
if selector.get("kind") or selector.get("name") or selector.get("guid"):
listed = rpc(base_url, "metadata.objects.list", selector | {"limit": 5, "table": source_table}, timeout)
objects = [item for item in listed.get("objects") or [] if isinstance(item, dict)]
if objects:
return objects[0]
found = rpc(base_url, "metadata.definition.find", selector | {"areas": ["metadata"], "include_storage": True}, timeout)
matches = [item for item in found.get("matches") or found.get("items") or [] if isinstance(item, dict)]
for item in matches:
if item.get("guid") or (item.get("object") or {}).get("guid"):
return item.get("object") if isinstance(item.get("object"), dict) else item
raise RuntimeError(f"Object selector did not resolve: {selector}")
def object_selector(base_id: str, obj: dict[str, Any], *, default_table: str | None = None) -> dict[str, Any]:
selector = {"base_id": base_id, "include_storage": True}
storage = obj.get("storage") if isinstance(obj.get("storage"), dict) else {}
table = storage.get("table") or default_table
if table in SOURCE_TABLES:
selector["table"] = table
for key in ("kind", "name", "guid"):
if obj.get(key):
selector[key] = obj[key]
return selector
def add_source_row(rows: list[dict[str, Any]], *, role: str, table: str | None, file_name: str | None, extra: dict[str, Any] | None = None) -> None:
if table not in SOURCE_TABLES or not file_name:
return
row = {"role": role, "table": table, "file_name": file_name}
if extra:
row.update(extra)
if not any(existing.get("table") == table and existing.get("file_name") == file_name and existing.get("role") == role for existing in rows):
rows.append(row)
def collect_source_rows(base_url: str, base_id: str, obj: dict[str, Any], timeout: float) -> tuple[list[dict[str, Any]], dict[str, Any]]:
storage = obj.get("storage") if isinstance(obj.get("storage"), dict) else {}
selector = object_selector(base_id, obj, default_table=storage.get("table") or "Config")
rows: list[dict[str, Any]] = []
evidence: dict[str, Any] = {"forms": None, "modules": None}
add_source_row(rows, role="object", table=storage.get("table") or "Config", file_name=obj.get("guid"))
forms = rpc(base_url, "metadata.object.forms", selector, timeout)
evidence["forms"] = {"status": forms.get("status"), "counts": forms.get("counts")}
for form in forms.get("forms") or []:
if not isinstance(form, dict):
continue
source = form.get("source") if isinstance(form.get("source"), dict) else {}
add_source_row(
rows,
role="form",
table=source.get("table"),
file_name=source.get("file_name"),
extra={"name": form.get("name"), "guid": form.get("guid")},
)
add_source_row(
rows,
role="form_payload",
table=source.get("table"),
file_name=f"{source.get('file_name')}.0" if source.get("file_name") else None,
extra={"name": form.get("name"), "guid": form.get("guid"), "suffix": ".0"},
)
modules = rpc(base_url, "metadata.object.modules", selector, timeout)
evidence["modules"] = {"status": modules.get("status"), "counts": modules.get("counts")}
for module in modules.get("modules") or []:
if not isinstance(module, dict):
continue
add_source_row(
rows,
role="module",
table=module.get("table"),
file_name=module.get("file_name"),
extra={
"module_id": module.get("module_id"),
"stream_index": module.get("stream_index"),
"sha1": module.get("sha1"),
"bytes": module.get("bytes"),
},
)
return rows, evidence
def read_row_details(base_url: str, base_id: str, rows: list[dict[str, Any]], timeout: float) -> dict[str, Any]:
details: dict[str, Any] = {}
by_table: dict[str, list[str]] = {}
for row in rows:
table = str(row.get("table") or "")
file_name = str(row.get("file_name") or "")
if table in SOURCE_TABLES and file_name:
by_table.setdefault(table, []).append(file_name)
for table, names in sorted(by_table.items()):
unique_names = sorted(set(names))
query = (
"SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes, "
"CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1 "
f"FROM {table} WHERE FileName IN ({', '.join(sql_literal(name) for name in unique_names)})"
)
result = rpc(
base_url,
"query.run",
{"base_id": base_id, "diagnostic": True, "query": query, "timeout_seconds": int(timeout)},
timeout,
)
details[table] = {
"status": result.get("status"),
"rows": result.get("rows") or [],
"counts": result.get("counts"),
}
return details
def read_target_collisions(base_url: str, base_id: str, rows: list[dict[str, Any]], target_table: str, timeout: float) -> dict[str, Any]:
file_names = sorted({str(row.get("file_name") or "") for row in rows if row.get("file_name")})
if not file_names:
return {"status": "skipped_no_source_rows", "rows": [], "counts": {"rows": 0}}
query = (
"SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes, "
"CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1 "
f"FROM {target_table} WHERE FileName IN ({', '.join(sql_literal(name) for name in file_names)})"
)
result = rpc(
base_url,
"query.run",
{"base_id": base_id, "diagnostic": True, "query": query, "timeout_seconds": int(timeout)},
timeout,
)
collisions = result.get("rows") or []
return {
"status": "collision" if collisions else "clear",
"table": target_table,
"rows": collisions,
"counts": result.get("counts"),
}
def build_report(args: argparse.Namespace) -> dict[str, Any]:
expected_source_table = SOURCE_BY_TARGET[args.target_table]
selector = selector_from_args(args)
obj = resolve_object(args.base_url, selector, expected_source_table, args.timeout)
rows, evidence = collect_source_rows(args.base_url, args.base_id, obj, args.timeout)
row_details = read_row_details(args.base_url, args.base_id, rows, args.timeout)
target_collisions = read_target_collisions(args.base_url, args.base_id, rows, args.target_table, args.timeout)
found_rows = sum(len((table_details.get("rows") or [])) for table_details in row_details.values() if isinstance(table_details, dict))
active_source_tables = sorted({str(row.get("table")) for row in rows if row.get("table") in SOURCE_TABLES})
mismatched_source_tables = sorted(table for table in active_source_tables if table != expected_source_table)
collision_rows = target_collisions.get("rows") if isinstance(target_collisions.get("rows"), list) else []
if mismatched_source_tables:
status = "blocked_source_target_family_mismatch"
elif collision_rows:
status = "blocked_target_collision"
elif rows and found_rows:
status = "plan_ready"
else:
status = "blocked_no_active_source_rows"
recommendations = [
"Do not edit Config/ConfigCAS directly for the smoke. Prepare the save layer first, then run strict readiness.",
f"Copy the target object's required active rows from {expected_source_table} into {args.target_table} using the approved saved-state workflow.",
"After the copy, rerun check_1c_saved_state_strict_readiness.py and only then enable strict saved-state smoke.",
]
if mismatched_source_tables:
recommendations.insert(1, f"{args.target_table} must be prepared only from {expected_source_table}; found source table(s): {', '.join(mismatched_source_tables)}.")
if collision_rows:
recommendations.insert(1, f"{args.target_table} already contains one or more planned FileName values; inspect/clear the existing unactivated change before copying.")
return {
"schema": "onec_saved_state_copy_plan.v1",
"base_url": args.base_url,
"base_id": args.base_id,
"status": status,
"ready_to_copy": status == "plan_ready",
"selector": selector,
"object": {
"guid": obj.get("guid"),
"kind": obj.get("kind"),
"name": obj.get("name"),
"synonym": obj.get("synonym"),
"source": obj.get("source"),
},
"target": {
"table": args.target_table,
"mode": "prepare_saved_state_working_copy",
},
"source_family": {
"target_table": args.target_table,
"expected_source_table": expected_source_table,
"source_tables": active_source_tables,
"mismatched_source_tables": mismatched_source_tables,
"valid": not mismatched_source_tables,
},
"source_rows": rows,
"source_row_details": row_details,
"target_collisions": target_collisions,
"evidence": evidence,
"summary": {
"planned_source_rows": len(rows),
"found_source_storage_rows": found_rows,
"target_collision_rows": len(collision_rows),
"active_source_tables": active_source_tables,
},
"recommendations": recommendations,
}
def main() -> int:
parser = argparse.ArgumentParser(description="Read-only plan for preparing a 1C saved-state copy from an active object.")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
parser.add_argument("--base-id", default="upo_test")
parser.add_argument("--ref")
parser.add_argument("--kind")
parser.add_argument("--name")
parser.add_argument("--guid")
parser.add_argument("--target-table", choices=sorted(TARGET_TABLES), default="ConfigSave")
parser.add_argument("--timeout", type=float, default=60.0)
parser.add_argument("--report", type=Path)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
try:
report = build_report(args)
except Exception as exc:
report = {
"schema": "onec_saved_state_copy_plan.v1",
"base_url": args.base_url,
"base_id": args.base_id,
"status": "error",
"ready_to_copy": False,
"error": str(exc),
}
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if args.json or report["status"] != "plan_ready":
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print(
"OK: saved-state copy plan ready for "
f"{report['object'].get('kind')}.{report['object'].get('name')} -> {report['target'].get('table')}."
)
return 0 if report["status"] == "plan_ready" else 1
if __name__ == "__main__":
raise SystemExit(main())