Files
llm/scripts/smoke_1c_code_write_saved_state.py
T
2026-08-14 09:40:51 +03:00

421 lines
20 KiB
Python

from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from typing import Any
DEFAULT_ADAPTER_URL = "http://docker.cin.su:8011"
DEFAULT_MCP_URL = "http://docker.cin.su:8021"
EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1"
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8")
def rpc(adapter_url: str, method: str, payload: dict[str, Any], *, timeout: int = 60) -> dict[str, Any]:
body = json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8")
service_token = os.environ.get("ONEC_ADAPTER_SERVICE_TOKEN", "").strip()
headers = {"Content-Type": "application/json; charset=utf-8"}
if service_token:
headers["Authorization"] = f"Bearer {service_token}"
request = urllib.request.Request(
adapter_url.rstrip("/") + "/rpc",
data=body,
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"{method} HTTP {exc.code}: {detail}") from exc
def post_json(url: str, payload: dict[str, Any], *, timeout: int, headers: dict[str, str] | None = None) -> tuple[dict[str, str], dict[str, Any]]:
request = urllib.request.Request(
url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json", "Accept": "application/json, text/event-stream", **(headers or {})},
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8")
data = json.loads(raw) if raw else {}
return dict(response.headers), data if isinstance(data, dict) else {"status": "error", "error": "response_not_object", "response": data}
def mcp_initialize(mcp_url: str, timeout: int) -> tuple[str | None, dict[str, Any]]:
body = {
"jsonrpc": "2.0",
"id": "code-write-saved-state-initialize",
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "smoke_1c_code_write_saved_state", "version": "1"},
},
}
try:
headers, data = post_json(mcp_url.rstrip("/") + "/mcp", body, timeout=timeout)
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
return None, {"status": "error", "error": "mcp_initialize_http_error", "http_status": exc.code, "response": raw}
except urllib.error.URLError as exc:
return None, {"status": "error", "error": "mcp_initialize_url_error", "message": str(exc)}
if "error" in data:
return None, {"status": "error", "error": "mcp_initialize_jsonrpc_error", "details": data.get("error")}
return headers.get("Mcp-Session-Id"), data
def mcp_rpc(mcp_url: str, method: str, payload: dict[str, Any], *, timeout: int, session_id: str | None) -> dict[str, Any]:
headers: dict[str, str] = {}
if session_id:
headers["Mcp-Session-Id"] = session_id
body = {
"jsonrpc": "2.0",
"id": f"code-write-saved-state-{method}",
"method": "tools/call",
"params": {"name": "onec_request", "arguments": {"method": method, "payload": payload}},
}
try:
_headers, data = post_json(mcp_url.rstrip("/") + "/mcp", body, timeout=timeout, headers=headers)
except urllib.error.HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
return {"status": "error", "error": "mcp_http_error", "http_status": exc.code, "response": raw}
except urllib.error.URLError as exc:
return {"status": "error", "error": "mcp_url_error", "message": str(exc)}
if "error" in data:
return {"status": "error", "error": "mcp_jsonrpc_error", "details": data.get("error")}
result = data.get("result") if isinstance(data.get("result"), dict) else {}
content = result.get("content") if isinstance(result.get("content"), list) else []
first = content[0] if content and isinstance(content[0], dict) else {}
text = first.get("text")
if not isinstance(text, str):
return {"status": "error", "error": "mcp_tool_text_missing", "result": result}
try:
decoded = json.loads(text)
except json.JSONDecodeError as exc:
return {"status": "error", "error": "mcp_tool_text_not_json", "message": str(exc), "text": text[:500]}
return decoded if isinstance(decoded, dict) else {"status": "error", "error": "mcp_tool_payload_not_object", "payload": decoded}
def rpc_call(endpoint_url: str, method: str, payload: dict[str, Any], *, transport: str, timeout: int, session_id: str | None) -> dict[str, Any]:
if transport == "mcp":
payload = {**payload, "mcp_sync": True}
return mcp_rpc(endpoint_url, method, payload, timeout=timeout, session_id=session_id)
return rpc(endpoint_url, method, payload, timeout=timeout)
def require(condition: bool, message: str, failures: list[str]) -> None:
if not condition:
failures.append(message)
def require_saved_state_write(result: dict[str, Any], label: str, failures: list[str]) -> None:
write_mode = result.get("write_mode") if isinstance(result.get("write_mode"), dict) else {}
require(result.get("status") == "applied", f"{label} must return status=applied", failures)
require(result.get("applied") is True, f"{label} must set applied=true", failures)
require(write_mode.get("target") == "saved_state", f"{label} must target saved_state", failures)
require(write_mode.get("activation_state") == "not_activated", f"{label} must not activate runtime state", failures)
def first_unique_fragment(text: str) -> str:
for line in text.splitlines():
candidate = line.strip()
if candidate and text.count(candidate) == 1:
return candidate
return text
def is_optional_write_gate_block(result: dict[str, Any]) -> bool:
return result.get("status") == "blocked" and result.get("error") in {
"needs_repository_lock",
"support_gate_blocked",
}
def main() -> int:
parser = argparse.ArgumentParser(description="Smoke-test agent-facing 1C code.write saved-state workflow.")
parser.add_argument("--transport", choices=("rest", "mcp"), default="rest")
parser.add_argument("--adapter-url", default=DEFAULT_ADAPTER_URL)
parser.add_argument("--mcp-url", default=DEFAULT_MCP_URL)
parser.add_argument("--base-id", default="upo_test")
parser.add_argument("--extension", default="test2")
parser.add_argument("--object-type", default="CommonForm")
parser.add_argument("--object-name", default="t_Форма")
parser.add_argument("--routine-name", default="ЗаменаДомена")
parser.add_argument("--allow-missing-target", action="store_true")
parser.add_argument(
"--allow-blocked-write-gate",
action="store_true",
help="Skip successfully when the target exists but repository/support policy blocks the first write.",
)
parser.add_argument("--timeout", type=int, default=60)
parser.add_argument("--report", type=str)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
endpoint_url = args.mcp_url if args.transport == "mcp" else args.adapter_url
selector = {
"base_id": args.base_id,
"object_type": args.object_type,
"object_name": args.object_name,
"routine_name": args.routine_name,
"include_text": True,
"max_chars": 4000,
}
failures: list[str] = []
steps: list[dict[str, Any]] = []
session_id: str | None = None
initialize_result: dict[str, Any] | None = None
if args.transport == "mcp":
session_id, initialize_result = mcp_initialize(endpoint_url, args.timeout)
contract_version = (((initialize_result.get("result") or {}).get("serverInfo") or {}).get("contract_version") if isinstance(initialize_result.get("result"), dict) else None)
steps.append({"name": "mcp.initialize", "status": "ok" if session_id and contract_version == EXPECTED_CONTRACT_VERSION else "error", "contract_version": contract_version})
require(bool(session_id), "MCP initialize must return Mcp-Session-Id", failures)
require(contract_version == EXPECTED_CONTRACT_VERSION, "MCP initialize must report expected contract version", failures)
before = rpc_call(endpoint_url, "code.read", {**selector, "state": "working"}, transport=args.transport, timeout=args.timeout, session_id=session_id)
steps.append({"name": "code.read working before", "status": before.get("status")})
if args.allow_missing_target and before.get("status") != "ok":
report = {
"schema": "onec_code_write_saved_state_smoke.v1",
"status": "skipped_missing_target",
"skipped": True,
"endpoint_url": endpoint_url,
"transport": args.transport,
"base_id": args.base_id,
"target": {"object_type": args.object_type, "object_name": args.object_name, "routine_name": args.routine_name},
"steps": steps,
"failures": [],
**({"mcp_initialize": initialize_result} if initialize_result and args.transport == "mcp" else {}),
}
if args.report:
os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True)
with open(args.report, "w", encoding="utf-8") as handle:
json.dump(report, handle, ensure_ascii=False, indent=2)
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print(f"1C code.write saved-state smoke: {report['status']}")
return 0
require(before.get("status") == "ok", "working code.read before write must be ok", failures)
routine_text = str(before.get("text") or "")
require(bool(routine_text.strip()), "working code.read must return routine text", failures)
write = rpc_call(
endpoint_url,
"code.write",
{
"base_id": args.base_id,
"object_type": args.object_type,
"object_name": args.object_name,
"routine_name": args.routine_name,
"routine_text": routine_text,
"mode": "apply",
},
transport=args.transport,
timeout=args.timeout,
session_id=session_id,
)
steps.append(
{
"name": "code.write apply",
"status": write.get("status"),
"applied": write.get("applied"),
"write_mode": write.get("write_mode"),
}
)
if args.allow_blocked_write_gate and is_optional_write_gate_block(write):
report = {
"schema": "onec_code_write_saved_state_smoke.v1",
"status": "skipped_write_gate",
"skipped": True,
"endpoint_url": endpoint_url,
"transport": args.transport,
"base_id": args.base_id,
"target": {"object_type": args.object_type, "object_name": args.object_name, "routine_name": args.routine_name},
"steps": steps,
"write_gate": {
"status": write.get("status"),
"error": write.get("error"),
"diagnostics": write.get("diagnostics"),
},
"failures": [],
**({"mcp_initialize": initialize_result} if initialize_result and args.transport == "mcp" else {}),
}
if args.report:
os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True)
with open(args.report, "w", encoding="utf-8") as handle:
json.dump(report, handle, ensure_ascii=False, indent=2)
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print(f"1C code.write saved-state smoke: {report['status']}")
return 0
write_mode = write.get("write_mode") if isinstance(write.get("write_mode"), dict) else {}
require_saved_state_write(write, "code.write routine apply", failures)
after = rpc_call(endpoint_url, "code.read", {**selector, "state": "working"}, transport=args.transport, timeout=args.timeout, session_id=session_id)
steps.append({"name": "code.read working after", "status": after.get("status"), "current_state": after.get("current_state")})
require(after.get("status") == "ok", "working code.read after write must be ok", failures)
require(after.get("text") == routine_text, "no-op code.write must preserve routine text", failures)
current_state = after.get("current_state") if isinstance(after.get("current_state"), dict) else {}
require(current_state.get("source") == "saved_state", "working code.read after write must read saved_state", failures)
fragment = first_unique_fragment(routine_text)
fragment_write = rpc_call(
endpoint_url,
"code.write",
{
"base_id": args.base_id,
"object_type": args.object_type,
"object_name": args.object_name,
"routine_name": args.routine_name,
"old": fragment,
"new": fragment,
"mode": "apply",
},
transport=args.transport,
timeout=args.timeout,
session_id=session_id,
)
steps.append(
{
"name": "code.write fragment apply",
"status": fragment_write.get("status"),
"applied": fragment_write.get("applied"),
"write_mode": fragment_write.get("write_mode"),
}
)
require_saved_state_write(fragment_write, "code.write fragment apply", failures)
after_fragment = rpc_call(endpoint_url, "code.read", {**selector, "state": "working"}, transport=args.transport, timeout=args.timeout, session_id=session_id)
steps.append({"name": "code.read working after fragment", "status": after_fragment.get("status"), "current_state": after_fragment.get("current_state")})
require(after_fragment.get("status") == "ok", "working code.read after fragment write must be ok", failures)
require(after_fragment.get("text") == routine_text, "no-op fragment code.write must preserve routine text", failures)
module_selector = {key: value for key, value in selector.items() if key != "routine_name"}
module_selector["max_chars"] = 200000
module_before = rpc_call(endpoint_url, "code.read", {**module_selector, "state": "working"}, transport=args.transport, timeout=args.timeout, session_id=session_id)
module_text = str(module_before.get("text") or "")
steps.append({"name": "code.read full module before", "status": module_before.get("status"), "current_state": module_before.get("current_state"), "has_text": bool(module_text.strip())})
require(module_before.get("status") == "ok", "working full module code.read before module write must be ok", failures)
require(bool(module_text.strip()), "working full module code.read must return module text", failures)
require("///----" not in module_text, "working full module code.read must hide the saved form module container marker", failures)
module_write = rpc_call(
endpoint_url,
"code.write",
{
"base_id": args.base_id,
"object_type": args.object_type,
"object_name": args.object_name,
"module_text": module_text,
"mode": "apply",
},
transport=args.transport,
timeout=args.timeout,
session_id=session_id,
)
steps.append(
{
"name": "code.write full module apply",
"status": module_write.get("status"),
"applied": module_write.get("applied"),
"write_mode": module_write.get("write_mode"),
}
)
require_saved_state_write(module_write, "code.write full module apply", failures)
module_after = rpc_call(endpoint_url, "code.read", {**module_selector, "state": "working"}, transport=args.transport, timeout=args.timeout, session_id=session_id)
steps.append({"name": "code.read full module after", "status": module_after.get("status"), "current_state": module_after.get("current_state")})
require(module_after.get("status") == "ok", "working full module code.read after module write must be ok", failures)
require(module_after.get("text") == module_text, "no-op full module code.write must preserve module text", failures)
require("///----" not in str(module_after.get("text") or ""), "working full module code.read after write must hide marker", failures)
both = rpc_call(endpoint_url, "code.read", {**selector, "state": "both"}, transport=args.transport, timeout=args.timeout, session_id=session_id)
steps.append({"name": "code.read both", "status": both.get("status"), "comparison": both.get("comparison"), "text_source": both.get("text_source")})
require(both.get("status") == "ok", "code.read state=both must be ok", failures)
require(both.get("text_source") == "saved_state", "state=both effective text must come from saved_state when present", failures)
found = rpc_call(
endpoint_url,
"extension.objects.find",
{
"base_id": args.base_id,
"extension": args.extension,
"object_type": args.object_type,
"query": args.object_name,
"state": "working",
"limit": 20,
},
transport=args.transport,
timeout=args.timeout,
session_id=session_id,
)
objects = [item for item in found.get("objects") or [] if isinstance(item, dict) and item.get("name") == args.object_name]
steps.append({"name": "extension.objects.find working", "status": found.get("status"), "matches": len(objects)})
require(bool(objects), "working extension.objects.find must see target form", failures)
require(str((objects[0].get("activation_state") if objects else "") or "").startswith("saved_"), "target form must be marked as saved-state in working view", failures)
saved_forms = rpc_call(
endpoint_url,
"metadata.saved_state.forms.search",
{
"base_id": args.base_id,
"extension": args.extension,
"form": args.object_name,
"limit": 20,
},
transport=args.transport,
timeout=args.timeout,
session_id=session_id,
)
saved_form_matches = [
item
for item in saved_forms.get("forms") or []
if isinstance(item, dict) and item.get("name") == args.object_name and item.get("table") in {"ConfigSave", "ConfigCASSave"}
]
steps.append({"name": "metadata.saved_state.forms.search", "status": saved_forms.get("status"), "matches": len(saved_form_matches)})
require(saved_forms.get("status") == "ok", "saved-state form search must be ok after code.write", failures)
require(bool(saved_form_matches), "saved-state form search must find the target form after code.write", failures)
if saved_form_matches:
counts = saved_form_matches[0].get("counts") if isinstance(saved_form_matches[0].get("counts"), dict) else {}
require(int(counts.get("items_total") or 0) > 0, "saved-state form structure must have decoded items after code.write", failures)
report = {
"schema": "onec_code_write_saved_state_smoke.v1",
"status": "failed" if failures else "ok",
"endpoint_url": endpoint_url,
"transport": args.transport,
"base_id": args.base_id,
"target": {"object_type": args.object_type, "object_name": args.object_name, "routine_name": args.routine_name},
"steps": steps,
"failures": failures,
**({"mcp_initialize": initialize_result} if initialize_result and args.transport == "mcp" and failures else {}),
}
if args.report:
os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True)
with open(args.report, "w", encoding="utf-8") as handle:
json.dump(report, handle, ensure_ascii=False, indent=2)
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print(f"1C code.write saved-state smoke: {report['status']}")
for step in steps:
print(f"- {step['name']}: {step.get('status')}")
for failure in failures:
print(f"FAIL: {failure}", file=sys.stderr)
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(main())