diff --git a/plugins/1c/connector/README.md b/plugins/1c/connector/README.md index b833cb7..bc9d7f6 100644 --- a/plugins/1c/connector/README.md +++ b/plugins/1c/connector/README.md @@ -308,18 +308,20 @@ dimension, or resource, also pass `member_ref` or `member_kind` + `member_name`; the adapter resolves the exact parent/member GUIDs and serialized tree path internally. The method supports `plan`, `apply`, `apply_and_verify`, and `apply_and_rollback`, requires explicit saved-state -gates, and never writes active `Config`/`ConfigCAS`. Renaming an object and -member, adding/removing collection items, and adding a new synonym locale -remain intentionally disabled. +gates, and never writes active `Config`/`ConfigCAS`. Renaming an object or +member, removing collection items, and adding a new synonym locale remain +intentionally disabled. `metadata.object.member.add` adds one new object requisite or tabular-section column (`Attribute`) by cloning an existing attribute in the same collection. The caller passes only `template_member_ref`, `new_member_name`, and optionally synonym/comment; the adapter generates the GUID, preserves the template's type/settings, appends to -the exact declared collection, and verifies the new identity after apply. -Arbitrary type construction and deletion are not supported by this first -structural route. +the exact declared collection, and verifies the new identity, empty/default +comment, container, and preserved non-identity settings after apply. A template +is blocked when its GUID or name is referenced outside its declared identity +fields. Arbitrary type construction and deletion are not supported by this +first structural route. Managed form bodies in base `Config` are resolved from the public form GUID to the sibling `.0` SQL payload. Command-bar buttons expose public command diff --git a/plugins/1c/connector/adapter_1c_server.py b/plugins/1c/connector/adapter_1c_server.py index aa77e77..e24dbcc 100644 --- a/plugins/1c/connector/adapter_1c_server.py +++ b/plugins/1c/connector/adapter_1c_server.py @@ -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)) diff --git a/plugins/1c/mcp/adapter_1c_mcp.py b/plugins/1c/mcp/adapter_1c_mcp.py index 8c3a902..2366afa 100644 --- a/plugins/1c/mcp/adapter_1c_mcp.py +++ b/plugins/1c/mcp/adapter_1c_mcp.py @@ -394,6 +394,32 @@ TOOLS = [ "mode": "plan", }, }, + { + "method": "metadata.write", + "payload": { + "base_id": "", + "target": { + "area": "object", + "ref": "Справочник.", + "property": "synonym", + }, + "value": "", + "mode": "plan", + }, + }, + { + "method": "metadata.write", + "payload": { + "base_id": "", + "target": { + "area": "object", + "operation": "add_attribute", + "template_member_ref": "Справочник..Реквизит.", + "new_member_name": "", + }, + "mode": "plan", + }, + }, { "method": "metadata.form.command_button.write", "payload": { diff --git a/tests/1c/test_object_property_write.py b/tests/1c/test_object_property_write.py index e5e2508..12ee28b 100644 --- a/tests/1c/test_object_property_write.py +++ b/tests/1c/test_object_property_write.py @@ -291,6 +291,25 @@ def test_object_property_plan_writes_existing_member_by_name(monkeypatch) -> Non def test_object_property_apply_verify_and_rollback(monkeypatch) -> None: install_common_mocks(monkeypatch) + monkeypatch.setattr( + adapter, + "metadata_saved_state_prepare", + lambda _payload: { + "status": "verified", + "applied": True, + "object": { + "kind": "Catalog", + "name": "Номенклатура", + "guid": OBJECT_GUID, + }, + "file_names": ["object.0"], + "apply_result": { + "prepare_receipt": { + "receipt_id": "a" * 32, + } + }, + }, + ) reads = iter([(b"old", {"database": "db"}, None), (b"new", {"database": "db"}, None)]) monkeypatch.setattr(adapter, "read_storage_file_bytes", lambda *_args, **_kwargs: next(reads)) monkeypatch.setattr( @@ -320,6 +339,19 @@ def test_object_property_apply_verify_and_rollback(monkeypatch) -> None: "storage_saved_state_rollback", lambda payload: {"status": "applied", "applied": True, "backup_id": payload["backup_id"]}, ) + prepare_rollback: dict[str, Any] = {} + + def fake_prepare_rollback(base_id: str, receipt_id: str, *, timeout_seconds: int) -> dict[str, Any]: + prepare_rollback.update( + { + "base_id": base_id, + "receipt_id": receipt_id, + "timeout_seconds": timeout_seconds, + } + ) + return {"status": "rolled_back", "applied": True} + + monkeypatch.setattr(adapter, "rollback_saved_state_prepare_receipt", fake_prepare_rollback) result = adapter.metadata_object_property_write( base_payload( @@ -332,6 +364,8 @@ def test_object_property_apply_verify_and_rollback(monkeypatch) -> None: assert result["status"] == "verified_and_rolled_back" assert result["applied"] is True assert result["rolled_back"] is True + assert result["prepare_rollback_result"]["status"] == "rolled_back" + assert prepare_rollback["receipt_id"] == "a" * 32 assert result["semantic_verification"] == { "status": "ok", "expected": "Номенклатура товаров", diff --git a/tests/1c/test_payload_codec.py b/tests/1c/test_payload_codec.py index 649b99a..45fd6aa 100644 --- a/tests/1c/test_payload_codec.py +++ b/tests/1c/test_payload_codec.py @@ -1062,7 +1062,8 @@ def test_metadata_write_routes_schedule_target_without_changing_existing_form_mo lambda payload: captured.update(payload) or {"schema": "onec_scheduled_job_schedule_write.v1", "status": "planned"}, ) - result = adapter_server.metadata_write( + result = adapter_server.call_method( + "metadata.write", { "base_id": "upo_test", "target": {"kind": "schedule", "ref": "РегламентныеЗадания.ОбменДанными"},