Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from urllib.error import HTTPError, URLError
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011"
|
||||
DEFAULT_MCP_URL = "http://docker.cin.su:8021"
|
||||
EXPECTED_CONTRACT_VERSION = "onec-selector-contract.v1"
|
||||
|
||||
|
||||
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,
|
||||
headers={"Content-Type": "application/json; charset=utf-8"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def post_json(url: str, payload: dict[str, Any], *, timeout: float, 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"),
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json", **(headers or {})},
|
||||
)
|
||||
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: float) -> tuple[str | None, dict[str, Any]]:
|
||||
body = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "write-plan-safety-initialize",
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke_1c_write_plan_safety", "version": "1"},
|
||||
},
|
||||
}
|
||||
try:
|
||||
headers, data = post_json(
|
||||
mcp_url.rstrip("/") + "/mcp",
|
||||
body,
|
||||
timeout=timeout,
|
||||
headers={"Accept": "application/json, text/event-stream"},
|
||||
)
|
||||
except 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 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: float, session_id: str | None) -> dict[str, Any]:
|
||||
headers = {"Accept": "application/json, text/event-stream"}
|
||||
if session_id:
|
||||
headers["Mcp-Session-Id"] = session_id
|
||||
body = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": f"write-plan-safety-{method}",
|
||||
"method": "tools/call",
|
||||
"params": {"name": "onec_request", "arguments": {"method": method, "payload": payload}},
|
||||
}
|
||||
try:
|
||||
_, data = post_json(mcp_url.rstrip("/") + "/mcp", body, timeout=timeout, headers=headers)
|
||||
except 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 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], timeout: float, *, transport: str, session_id: str | None) -> dict[str, Any]:
|
||||
if transport == "mcp":
|
||||
return mcp_rpc(endpoint_url, method, payload, timeout, session_id)
|
||||
return rpc(endpoint_url, method, payload, timeout)
|
||||
|
||||
|
||||
def problem_codes(result: dict[str, Any]) -> set[str]:
|
||||
return {str(problem.get("code") or "") for problem in result.get("problems") or [] if isinstance(problem, dict)}
|
||||
|
||||
|
||||
def require(condition: bool, message: str, failures: list[str]) -> None:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str) -> dict[str, Any]:
|
||||
failures: list[str] = []
|
||||
checks: dict[str, Any] = {}
|
||||
session_id: str | None = None
|
||||
initialize_result: dict[str, Any] | None = None
|
||||
if transport == "mcp":
|
||||
session_id, initialize_result = mcp_initialize(endpoint_url, timeout)
|
||||
contract_version = (((initialize_result.get("result") or {}).get("serverInfo") or {}).get("contract_version") if isinstance(initialize_result.get("result"), dict) else None)
|
||||
checks["mcp.initialize"] = {
|
||||
"status": "ok" if session_id and contract_version == EXPECTED_CONTRACT_VERSION else "error",
|
||||
"session": bool(session_id),
|
||||
"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)
|
||||
missing_base_id = rpc_call(
|
||||
endpoint_url,
|
||||
"metadata.write.plan",
|
||||
{
|
||||
"target": {"kind": "module", "module_ref": "ConfigCASSave:placeholder.0#stream:0"},
|
||||
"intent": {"operation": "replace", "old": "old", "new": "new"},
|
||||
"timeout_seconds": int(timeout),
|
||||
},
|
||||
timeout,
|
||||
transport=transport,
|
||||
session_id=session_id,
|
||||
)
|
||||
checks["mcp.blocks_missing_base_id"] = {
|
||||
"schema": missing_base_id.get("schema"),
|
||||
"status": missing_base_id.get("status"),
|
||||
"reason": missing_base_id.get("reason"),
|
||||
"method": missing_base_id.get("method"),
|
||||
}
|
||||
require(missing_base_id.get("schema") == "adapter_1c_mcp_policy.v1", "MCP missing-base response must come from MCP policy", failures)
|
||||
require(missing_base_id.get("status") == "blocked", "MCP must block live methods without base_id", failures)
|
||||
require(missing_base_id.get("reason") == "base_id_required", "MCP missing-base block must use base_id_required reason", failures)
|
||||
diagnostic_fallback = rpc_call(
|
||||
endpoint_url,
|
||||
"storage.files.list",
|
||||
{
|
||||
"base_id": base_id,
|
||||
"table": "Config",
|
||||
"limit": 1,
|
||||
"timeout_seconds": int(timeout),
|
||||
},
|
||||
timeout,
|
||||
transport=transport,
|
||||
session_id=session_id,
|
||||
)
|
||||
checks["mcp.blocks_diagnostic_fallback"] = {
|
||||
"schema": diagnostic_fallback.get("schema"),
|
||||
"status": diagnostic_fallback.get("status"),
|
||||
"reason": diagnostic_fallback.get("reason"),
|
||||
"method": diagnostic_fallback.get("method"),
|
||||
}
|
||||
require(diagnostic_fallback.get("schema") == "adapter_1c_mcp_policy.v1", "MCP diagnostic fallback response must come from MCP policy", failures)
|
||||
require(diagnostic_fallback.get("status") == "blocked", "MCP must block low-level diagnostic fallback methods", failures)
|
||||
require(diagnostic_fallback.get("reason") == "diagnostic_method", "MCP diagnostic fallback block must use diagnostic_method reason", failures)
|
||||
|
||||
blocked_effective = rpc_call(
|
||||
endpoint_url,
|
||||
"metadata.write",
|
||||
{
|
||||
"base_id": base_id,
|
||||
"target": {"canonical_path": "Документ.АвансовыйОтчет.Форма.ФормаДокумента.КнопкаЗаписать"},
|
||||
"mode": "apply",
|
||||
"edits": [{"property": "Заголовок", "value": "BLOCKED_SMOKE"}],
|
||||
"allow_sql_saved_state_apply": True,
|
||||
"timeout_seconds": int(timeout),
|
||||
},
|
||||
timeout,
|
||||
transport=transport,
|
||||
session_id=session_id,
|
||||
)
|
||||
checks["blocked_effective_form_path"] = {
|
||||
"status": blocked_effective.get("status"),
|
||||
"error": blocked_effective.get("error"),
|
||||
"routed_method": blocked_effective.get("routed_method"),
|
||||
"target_kind": blocked_effective.get("target_kind"),
|
||||
}
|
||||
require(blocked_effective.get("status") == "blocked", "effective canonical path write must be blocked", failures)
|
||||
require(blocked_effective.get("error") == "write_plan_required", "effective canonical path write must require write plan", failures)
|
||||
require(blocked_effective.get("routed_method") == "metadata.write.plan", "effective canonical path must route to metadata.write.plan", failures)
|
||||
|
||||
blocked_control = rpc_call(
|
||||
endpoint_url,
|
||||
"metadata.write.plan",
|
||||
{
|
||||
"base_id": base_id,
|
||||
"target": {"kind": "module", "module_ref": "ConfigCASSave:placeholder.0#stream:0"},
|
||||
"intent": {"operation": "replace_with_control", "old": "old", "new": "new"},
|
||||
"timeout_seconds": int(timeout),
|
||||
},
|
||||
timeout,
|
||||
transport=transport,
|
||||
session_id=session_id,
|
||||
)
|
||||
blocked_codes = problem_codes(blocked_control)
|
||||
checks["replace_with_control_without_control_fragment"] = {
|
||||
"status": blocked_control.get("status"),
|
||||
"allowed": blocked_control.get("allowed"),
|
||||
"problem_codes": sorted(blocked_codes),
|
||||
}
|
||||
require(blocked_control.get("allowed") is False, "replace_with_control without control fragment must be blocked", failures)
|
||||
require("missing_control_fragment" in blocked_codes, "blocked replace_with_control must expose missing_control_fragment", failures)
|
||||
|
||||
allowed_control = rpc_call(
|
||||
endpoint_url,
|
||||
"metadata.write.plan",
|
||||
{
|
||||
"base_id": base_id,
|
||||
"target": {"kind": "module", "module_ref": "ConfigCASSave:placeholder.0#stream:0"},
|
||||
"intent": {
|
||||
"operation": "replace_with_control",
|
||||
"control_fragment": "old",
|
||||
"new": "new",
|
||||
},
|
||||
"timeout_seconds": int(timeout),
|
||||
},
|
||||
timeout,
|
||||
transport=transport,
|
||||
session_id=session_id,
|
||||
)
|
||||
route = allowed_control.get("route") if isinstance(allowed_control.get("route"), dict) else {}
|
||||
checks["replace_with_control_with_control_fragment"] = {
|
||||
"status": allowed_control.get("status"),
|
||||
"allowed": allowed_control.get("allowed"),
|
||||
"apply_method": route.get("apply_method"),
|
||||
"target_kind": (allowed_control.get("target") or {}).get("target_kind") if isinstance(allowed_control.get("target"), dict) else None,
|
||||
}
|
||||
require(allowed_control.get("allowed") is True, "replace_with_control with control fragment must be allowed for concrete route planning", failures)
|
||||
require(route.get("apply_method") == "metadata.module.write_apply", "allowed module write plan must route to metadata.module.write_apply", failures)
|
||||
|
||||
drift_control = rpc_call(
|
||||
endpoint_url,
|
||||
"metadata.write.plan",
|
||||
{
|
||||
"base_id": base_id,
|
||||
"target": {"kind": "module", "module_ref": "ConfigCASSave:placeholder.0#stream:0"},
|
||||
"intent": {
|
||||
"operation": "replace_with_control",
|
||||
"control_fragment": "old",
|
||||
"new": "new",
|
||||
"current_text": "changed",
|
||||
},
|
||||
"timeout_seconds": int(timeout),
|
||||
},
|
||||
timeout,
|
||||
transport=transport,
|
||||
session_id=session_id,
|
||||
)
|
||||
drift_codes = problem_codes(drift_control)
|
||||
checks["replace_with_control_drift"] = {
|
||||
"status": drift_control.get("status"),
|
||||
"allowed": drift_control.get("allowed"),
|
||||
"problem_codes": sorted(drift_codes),
|
||||
}
|
||||
require(drift_control.get("allowed") is False, "replace_with_control drift must be blocked when current_text is provided", failures)
|
||||
require("control_fragment_drift" in drift_codes, "replace_with_control drift must expose control_fragment_drift", failures)
|
||||
|
||||
return {
|
||||
"schema": "onec_write_plan_safety_smoke.v1",
|
||||
"status": "ok" if not failures else "failed",
|
||||
"endpoint_url": endpoint_url,
|
||||
"transport": transport,
|
||||
"base_id": base_id,
|
||||
"checks": checks,
|
||||
"failures": failures,
|
||||
**({"mcp_initialize": initialize_result} if initialize_result and failures else {}),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Smoke deployed 1C write-plan safety behavior without requiring saved-state rows.")
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--mcp-url", default=DEFAULT_MCP_URL)
|
||||
parser.add_argument("--transport", choices=("rest", "mcp"), default="rest")
|
||||
parser.add_argument("--base-id", default="upo_test")
|
||||
parser.add_argument("--timeout", type=float, default=30.0)
|
||||
parser.add_argument("--report", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
endpoint_url = args.mcp_url if args.transport == "mcp" else args.base_url
|
||||
report = run_smoke(endpoint_url, args.base_id, args.timeout, transport=args.transport)
|
||||
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))
|
||||
return 0 if report["status"] == "ok" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user