Complete reversible 1C saved-state prepare workflow

This commit is contained in:
2026-07-26 16:49:06 +03:00
parent 9fd4e1ff99
commit a9f95f6328
5 changed files with 431 additions and 17 deletions
+361 -10
View File
@@ -1005,7 +1005,7 @@ METHOD_INPUT_SCHEMAS["metadata.object.member.add"] = {
},
"new_member_name": {"type": "string", "description": "New 1C Attribute name."},
"new_member_synonym": {"type": "string", "description": "Optional ru synonym; defaults to the new name."},
"new_member_comment": {"type": "string", "description": "Optional comment; otherwise inherited from the template."},
"new_member_comment": {"type": "string", "description": "Optional comment; defaults to an empty string and is never inherited from the template."},
"extension": {"type": "string"},
"layer": {"type": "string", "enum": ["base_saved_state", "extension_saved_state"]},
"expected_sha1": {"type": "string"},
@@ -1018,6 +1018,89 @@ METHOD_INPUT_SCHEMAS["metadata.object.member.add"] = {
"include_storage": {"type": "boolean"},
},
}
METHOD_INPUT_SCHEMAS["metadata.write"] = {
"type": "object",
"required": ["base_id"],
"additionalProperties": True,
"properties": {
"base_id": {"type": "string", "description": "Adapter base identifier."},
"target": {
"type": "object",
"additionalProperties": True,
"description": "Name-first target. Use kind/area=object|form|module|schedule plus a public ref, kind/name, or canonical_path.",
"properties": {
"kind": {"type": "string", "enum": ["object", "form", "module", "schedule"]},
"area": {"type": "string", "enum": ["object", "form", "module", "schedule"]},
"ref": {"type": "string", "description": "Public 1C object/member ref."},
"canonical_path": {"type": "string", "description": "Public 1C code or metadata path."},
"property": {"type": "string"},
"operation": {
"type": "string",
"description": "Operation such as add_attribute, property_change, replace, or upsert_routine.",
},
"template_member_ref": {"type": "string"},
"new_member_name": {"type": "string"},
},
},
"target_kind": {"type": "string", "enum": ["object", "form", "module", "schedule"]},
"ref": {"type": "string", "description": "Optional public 1C object ref."},
"kind": {"type": "string", "description": "Optional metadata kind in English or Russian."},
"name": {"type": "string", "description": "Optional exact 1C object name."},
"object_type": {"type": "string", "description": "MCP alias of kind."},
"object_name": {"type": "string", "description": "MCP alias of name."},
"extension": {"type": "string"},
"property": {"type": "string"},
"value": {},
"schedule": {"type": "object", "additionalProperties": True},
"template_member_ref": {"type": "string"},
"new_member_name": {"type": "string"},
"new_member_synonym": {"type": "string"},
"new_member_comment": {"type": "string"},
"execution_mode": {
"type": "string",
"enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"],
},
"mode": {
"type": "string",
"enum": ["plan", "apply", "apply_and_verify", "apply_and_rollback"],
},
"allow_saved_state_write": {"type": "boolean"},
"allow_sql_saved_state_prepare": {"type": "boolean"},
"allow_sql_saved_state_apply": {"type": "boolean"},
"allow_sql_saved_state_rollback": {"type": "boolean"},
},
"examples": [
{
"base_id": "upo_test",
"target": {
"area": "object",
"ref": "Catalog.Номенклатура",
"property": "synonym",
},
"value": "Номенклатура",
"mode": "plan",
},
{
"base_id": "upo_test",
"target": {
"area": "object",
"operation": "add_attribute",
"template_member_ref": "Catalog.Номенклатура.Attribute.Артикул",
"new_member_name": "КодПоставщика",
},
"mode": "plan",
},
{
"base_id": "upo_test",
"target": {
"kind": "schedule",
"ref": "ScheduledJob.ОбменДанными",
},
"schedule": {"begin_time": "09:00:00"},
"mode": "plan",
},
],
}
METHOD_INPUT_SCHEMAS["metadata.code_index.build"] = {
"type": "object",
"required": ["base_id"],
@@ -3098,10 +3181,18 @@ METADATA_WRITE_CAPABILITIES = {
"form_element_properties": {
"status": "partial_saved_state",
"targets": ["form_element", "form_command", "form_attribute"],
"operations": ["plan_property_write", "apply_property_write"],
"operations": [
"plan_property_write",
"apply_property_write",
"move_sibling_target",
"upsert_command_button",
"upsert_handler_routine",
"verify_command_button_links",
],
"agent_method": "metadata.write",
"write_layer": "save",
"gaps": ["Complex command handler bindings and inherited form properties are not fully writable yet."],
"guards": ["expected_sha1", "backup", "semantic_readback", "cache_invalidation"],
"gaps": ["Cross-parent structural moves, arbitrary element deletion, complex command bindings, and inherited form properties are not fully writable yet."],
},
"object_metadata": {
"status": "partial_saved_state",
@@ -3109,8 +3200,8 @@ METADATA_WRITE_CAPABILITIES = {
"operations": ["write_synonym", "write_comment", "write_member_synonym", "write_member_comment", "add_attribute_from_template"],
"agent_method": "metadata.write",
"write_layer": "save",
"guards": ["expected_old", "expected_sha1", "backup", "semantic_readback"],
"gaps": ["Attribute add currently requires an existing Attribute template in the same collection. Object/member rename, arbitrary type construction, deletion, and other structural collection writes remain disabled."],
"guards": ["expected_old", "expected_sha1", "backup", "semantic_readback", "settings_shape_verification", "cache_invalidation"],
"gaps": ["Attribute add currently requires a safe existing Attribute template in the same collection. Object/member rename, arbitrary type construction, deletion, Dimension/Resource/TabularSection creation, and other structural collection writes remain disabled."],
},
"templates": {
"status": "read_only",
@@ -17521,6 +17612,7 @@ def apply_saved_state_prepare_copy(
file_names: list[str],
*,
expected_source_rows: int,
prepared_rows: list[dict[str, Any]],
timeout_seconds: int,
) -> dict[str, Any]:
method = "metadata.saved_state.prepare"
@@ -17573,6 +17665,13 @@ def apply_saved_state_prepare_copy(
"counts": {"expected_insert_rows": expected_source_rows, "inserted_rows": inserted},
"diagnostics": {"message": "Copied row count did not match current source row count."},
}
receipt = write_saved_state_prepare_receipt(
base_id=base_id,
config=config,
source_table=source_table,
target_table=target_table,
rows=prepared_rows,
)
conn.commit()
except Exception as exc:
try:
@@ -17601,6 +17700,7 @@ def apply_saved_state_prepare_copy(
"source": {"kind": "live_sql", "database": config["database"], "table": source_table},
"target": {"table": target_table},
"counts": {"inserted_rows": inserted, "file_names": len(names)},
"prepare_receipt": receipt,
"cache_invalidation": invalidate_adapter_caches_after_saved_state_change(
base_id,
reason="saved_state_prepare",
@@ -17609,6 +17709,220 @@ def apply_saved_state_prepare_copy(
}
def saved_state_prepare_receipt_dir() -> Path:
return storage_apply_backup_dir() / "prepare-receipts"
def write_saved_state_prepare_receipt(
*,
base_id: str,
config: dict[str, str],
source_table: str,
target_table: str,
rows: list[dict[str, Any]],
) -> dict[str, Any]:
receipt_id = uuid.uuid4().hex
created_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
root = saved_state_prepare_receipt_dir()
root.mkdir(parents=True, exist_ok=True)
path = root / f"{created_at.replace(':', '').replace('-', '')}-{receipt_id}.json"
normalized_rows = [
{
key: row.get(key)
for key in ("file_name", "part_no", "data_size", "binary_bytes", "binary_sha1")
}
for row in rows
]
evidence = {
"schema": "onec_saved_state_prepare_receipt.v1",
"receipt_id": receipt_id,
"created_at_utc": created_at,
"base_id": base_id,
"source": {
"server": config.get("server"),
"database": config.get("database"),
"table": source_table,
},
"target": {"table": target_table},
"rows": normalized_rows,
"status": "prepared",
}
path.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return {
"receipt_id": receipt_id,
"path": str(path),
"rows": len(normalized_rows),
}
def resolve_saved_state_prepare_receipt(receipt_id: str) -> Path | None:
normalized = str(receipt_id or "").strip().lower()
if not re.fullmatch(r"[0-9a-f]{32}", normalized):
return None
root = saved_state_prepare_receipt_dir()
matches = sorted(root.glob(f"*{normalized}.json")) if root.is_dir() else []
return matches[0] if len(matches) == 1 else None
def rollback_saved_state_prepare_receipt(
base_id: str,
receipt_id: str,
*,
timeout_seconds: int,
) -> dict[str, Any]:
path = resolve_saved_state_prepare_receipt(receipt_id)
if path is None:
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "not_found",
"applied": False,
"receipt_id": receipt_id,
}
try:
evidence = json.loads(path.read_text(encoding="utf-8-sig"))
except Exception as exc:
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "invalid_receipt",
"applied": False,
"receipt_id": receipt_id,
"diagnostics": {"message": str(exc)},
}
if str(evidence.get("base_id") or "") != str(base_id):
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "blocked",
"applied": False,
"error": "receipt_base_mismatch",
"receipt_id": receipt_id,
}
if evidence.get("status") == "rolled_back":
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "already_rolled_back",
"applied": True,
"receipt_id": receipt_id,
}
target = evidence.get("target") if isinstance(evidence.get("target"), dict) else {}
target_table = str(target.get("table") or "")
if target_table not in SAVED_STATE_SOURCE_BY_TARGET:
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "invalid_receipt",
"applied": False,
"error": "receipt_target_invalid",
"receipt_id": receipt_id,
}
expected_rows = [
row for row in (evidence.get("rows") or [])
if isinstance(row, dict) and row.get("file_name") and Path(str(row["file_name"])).name == str(row["file_name"])
]
file_names = sorted({str(row["file_name"]) for row in expected_rows})
if not expected_rows or not file_names:
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "invalid_receipt",
"applied": False,
"error": "receipt_rows_missing",
"receipt_id": receipt_id,
}
conn, config, error = connect_live_sql(base_id, "metadata.saved_state.prepare.rollback", timeout_seconds=timeout_seconds)
if error:
return error
started = time.time()
try:
cursor = conn.cursor(as_dict=True)
placeholders = ",".join(["%s"] * len(file_names))
cursor.execute(
f"""
SELECT FileName, PartNo, DataSize, DATALENGTH(BinaryData) AS BinaryBytes,
CONVERT(varchar(40), HASHBYTES('SHA1', BinaryData), 2) AS BinarySHA1
FROM dbo.[{target_table}] WITH (UPDLOCK, HOLDLOCK)
WHERE FileName IN ({placeholders})
ORDER BY FileName, PartNo
""",
tuple(file_names),
)
current_rows = [
saved_state_row_public({key: jsonable(value) for key, value in row.items()})
for row in cursor.fetchall()
]
comparable_fields = ("file_name", "part_no", "data_size", "binary_bytes", "binary_sha1")
expected_comparable = sorted(
tuple(row.get(field) for field in comparable_fields)
for row in expected_rows
)
current_comparable = sorted(
tuple(row.get(field) for field in comparable_fields)
for row in current_rows
)
if current_comparable != expected_comparable:
conn.rollback()
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "precondition_failed",
"applied": False,
"error": "prepared_rows_changed",
"receipt_id": receipt_id,
"counts": {
"expected_rows": len(expected_comparable),
"current_rows": len(current_comparable),
},
}
cursor.execute(
f"DELETE FROM dbo.[{target_table}] WHERE FileName IN ({placeholders})",
tuple(file_names),
)
deleted = int(cursor.rowcount or 0)
if deleted != len(expected_rows):
conn.rollback()
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "precondition_failed",
"applied": False,
"error": "prepared_row_delete_count_mismatch",
"receipt_id": receipt_id,
"counts": {"expected_rows": len(expected_rows), "deleted_rows": deleted},
}
conn.commit()
except Exception as exc:
try:
conn.rollback()
except Exception:
pass
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "error",
"applied": False,
"receipt_id": receipt_id,
"diagnostics": {"message": str(exc)},
}
finally:
try:
conn.close()
except Exception:
pass
evidence["status"] = "rolled_back"
evidence["rolled_back_at_utc"] = datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
path.write_text(json.dumps(evidence, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return {
"schema": "onec_saved_state_prepare_rollback.v1",
"status": "rolled_back",
"applied": True,
"base_id": base_id,
"receipt_id": receipt_id,
"target": {"table": target_table},
"counts": {"deleted_rows": deleted, "file_names": len(file_names)},
"cache_invalidation": invalidate_adapter_caches_after_saved_state_change(
base_id,
reason="saved_state_prepare_rollback",
),
"duration_ms": int((time.time() - started) * 1000),
}
def public_saved_state_prepare_object(object_card: dict[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(object_card, dict):
return None
@@ -17880,6 +18194,7 @@ def metadata_saved_state_prepare(payload: dict[str, Any]) -> dict[str, Any]:
target_table,
file_names,
expected_source_rows=len(source_rows),
prepared_rows=public_source_rows,
timeout_seconds=timeout_seconds,
)
result["apply_result"] = apply_result
@@ -42579,6 +42894,9 @@ def metadata_object_property_write(payload: dict[str, Any]) -> dict[str, Any]:
"diagnostics": prepare_result.get("diagnostics") or {"message": "Could not resolve a writable saved-state copy for the named object."},
}
return result if include_storage else sanitize_object_property_write_result(result)
prepare_apply_result = prepare_result.get("apply_result") if isinstance(prepare_result.get("apply_result"), dict) else {}
prepare_receipt = prepare_apply_result.get("prepare_receipt") if isinstance(prepare_apply_result.get("prepare_receipt"), dict) else {}
prepare_receipt_id = str(prepare_receipt.get("receipt_id") or "")
object_card = prepare_result.get("object") if isinstance(prepare_result.get("object"), dict) else {}
object_guid = str(object_card.get("guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower()
@@ -42787,7 +43105,22 @@ def metadata_object_property_write(payload: dict[str, Any]) -> dict[str, Any]:
}
)
result["rollback_result"] = rollback_result
result["rolled_back"] = bool(rollback_result.get("applied"))
payload_rolled_back = bool(rollback_result.get("applied"))
prepare_rollback_result = None
if payload_rolled_back and prepare_receipt_id:
prepare_rollback_result = rollback_saved_state_prepare_receipt(
base_id,
prepare_receipt_id,
timeout_seconds=timeout_seconds,
)
result["prepare_rollback_result"] = prepare_rollback_result
result["rolled_back"] = bool(
payload_rolled_back
and (
not prepare_receipt_id
or (prepare_rollback_result or {}).get("applied")
)
)
result["status"] = (
"verified_and_rolled_back"
if result["applied"] and verified and result["rolled_back"]
@@ -42903,6 +43236,9 @@ def metadata_object_member_add(payload: dict[str, Any]) -> dict[str, Any]:
"prepare_result": prepare_result,
}
return result if include_storage else sanitize_object_property_write_result(result)
prepare_apply_result = prepare_result.get("apply_result") if isinstance(prepare_result.get("apply_result"), dict) else {}
prepare_receipt = prepare_apply_result.get("prepare_receipt") if isinstance(prepare_apply_result.get("prepare_receipt"), dict) else {}
prepare_receipt_id = str(prepare_receipt.get("receipt_id") or "")
object_card = prepare_result.get("object") if isinstance(prepare_result.get("object"), dict) else {}
object_guid = str(object_card.get("guid") or payload.get("guid") or payload.get("object_guid") or "").strip().lower()
@@ -43148,7 +43484,22 @@ def metadata_object_member_add(payload: dict[str, Any]) -> dict[str, Any]:
}
)
result["rollback_result"] = rollback_result
result["rolled_back"] = bool(rollback_result.get("applied"))
payload_rolled_back = bool(rollback_result.get("applied"))
prepare_rollback_result = None
if payload_rolled_back and prepare_receipt_id:
prepare_rollback_result = rollback_saved_state_prepare_receipt(
base_id,
prepare_receipt_id,
timeout_seconds=timeout_seconds,
)
result["prepare_rollback_result"] = prepare_rollback_result
result["rolled_back"] = bool(
payload_rolled_back
and (
not prepare_receipt_id
or (prepare_rollback_result or {}).get("applied")
)
)
result["status"] = "verified_and_rolled_back" if verified and result["rolled_back"] else "applied_rollback_failed"
return result if include_storage else sanitize_object_property_write_result(result)
@@ -52211,14 +52562,14 @@ def validate_metadata_write_payload(payload: dict[str, Any]) -> dict[str, Any] |
target_dict = target if isinstance(target, dict) else {}
route_kind = payload.get("target_kind") or target_dict.get("target_kind") or target_dict.get("area") or payload.get("area")
if not route_kind and str(target_dict.get("kind") or "").strip().casefold() in {
"form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные"
"form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные", "schedule", "расписание"
}:
route_kind = target_dict.get("kind")
if not route_kind and str(payload.get("kind") or "").strip().casefold() in {"form", "форма", "module", "модуль", "bsl"}:
route_kind = payload.get("kind")
target_kind = str(route_kind or "form").strip().casefold()
if target_kind not in {"form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные"}:
return invalid_argument(method, "target.kind", "Only form, module, and object-property saved-state writes are currently routed.", allowed_values=["form", "module", "object"])
if target_kind not in {"form", "форма", "module", "модуль", "bsl", "object", "объект", "metadata", "метаданные", "schedule", "расписание"}:
return invalid_argument(method, "target.kind", "Only form, module, object, and scheduled-job schedule saved-state writes are currently routed.", allowed_values=["form", "module", "object", "schedule"])
mode = str(payload.get("execution_mode") or payload.get("mode") or "plan").strip().lower()
if mode not in FORM_ELEMENT_WRITE_APPLY_MODES:
return invalid_argument(method, "execution_mode", "Unsupported execution mode.", allowed_values=sorted(FORM_ELEMENT_WRITE_APPLY_MODES))