"""Apply or roll back MCP patches after human approval (worker-side).""" from __future__ import annotations import logging from collections.abc import Awaitable, Callable from typing import Any from app.config import settings from app.services import mcp_workspace logger = logging.getLogger(__name__) EmitFn = Callable[[int, str, str, dict | None], Awaitable[None]] async def backup_proposal_files(server: str, edits: list[dict]) -> dict[str, str]: """Snapshot current workspace content before a proposed patch is applied.""" backups: dict[str, str] = {} for edit in edits: path = edit.get("path") or edit.get("file") if not path: continue try: backups[path] = await mcp_workspace.read_source_file(server, path) except OSError: backups[path] = "" return backups async def restore_file_backups(server: str, backups: dict[str, str]) -> list[str]: restored: list[str] = [] for path, content in backups.items(): await mcp_workspace.write_source_file(server, path, content) restored.append(path) return restored async def apply_and_reload_mcp(server: str, edits: list[dict], manager) -> dict[str, Any]: written = await mcp_workspace.apply_file_edits(server, edits) if not written: return {"ok": False, "error": "No files written"} await manager.reload_server(server) return {"ok": True, "files": written, "tools": mcp_workspace.list_registered_tools(server)} async def queue_git_push_approval( *, task_id: int, server: str, written: list[str], commit_msg: str, summary: str | None, emit: EmitFn, ) -> dict[str, Any] | None: if not settings.gitea_token or not written or task_id <= 0: return None from app.database import SessionLocal from app.models.enums import ApprovalStatus from app.models.task import ApprovalRequest async with SessionLocal() as db: db.add( ApprovalRequest( task_id=task_id, tool_name="mcp_git_push", tool_args={ "server": server, "files": written, "commit_message": commit_msg, }, risk=summary, status=ApprovalStatus.pending, ) ) await db.commit() await emit( task_id, "approval", f"MCP patch applied — approve to push {server} to Gitea", {"server": server, "tool_name": "mcp_git_push", "files": written}, ) return {"queued": True, "tool_name": "mcp_git_push"} async def maybe_retry_tool_after_patch( *, task_id: int, retry_context: dict | None, manager, emit: EmitFn, ) -> dict[str, Any] | None: if not retry_context: return None device = retry_context.get("device") or "device" server = retry_context.get("server") or "" tool = retry_context.get("tool") or "" args = retry_context.get("args") or {} if not tool: return None from app.agent.mcp_helpers import tool_result_ok from app.agent.tool_events import tool_event_payload from app.mcp_manager.recovery import call_tool_resilient await emit(task_id, "log", f"Retrying {tool} after approved MCP patch…", {"server": server, "tool": tool}) res = await call_tool_resilient(manager, server, tool, args, task_id=task_id, emit=emit) ok = tool_result_ok(res) await emit( task_id, "tool_call", f"{device} › {tool} (after approved MCP patch)", tool_event_payload( device=device, tool=tool, args=args, status="done", ok=ok, output=res.get("text", ""), ), ) return {"ok": ok, "result": res} async def execute_approved_patch( *, task_id: int, tool_args: dict, manager, emit: EmitFn | None = None, push: bool | None = None, ) -> dict[str, Any]: """Apply a previously approved MCP patch, reload the server, optionally push/retry.""" from app.services.events import publish_event async def _emit(tid: int, kind: str, message: str, payload: dict | None = None) -> None: if emit: await emit(tid, kind, message, payload) elif tid: await publish_event(tid, kind, message, payload) server = tool_args.get("server") or "" edits = tool_args.get("files") or [] commit_msg = tool_args.get("commit_message") or "agentic-os: MCP patch" summary = tool_args.get("summary") retry_context = tool_args.get("retry_context") if not server or not edits: return {"ok": False, "error": "Invalid MCP patch approval payload"} await _emit( task_id, "mcp_dev", f"Applying approved MCP patch to {server}…", {"server": server, "phase": "apply", "files": [e.get("path") for e in edits]}, ) reload_result = await apply_and_reload_mcp(server, edits, manager) if not reload_result.get("ok"): await _emit(task_id, "error", reload_result.get("error", "MCP reload failed"), reload_result) return reload_result await _emit( task_id, "mcp_dev", f"MCP '{server}' reloaded — {len(reload_result.get('tools') or [])} tools registered", {"server": server, "phase": "reload_done", "tools": reload_result.get("tools")}, ) written = reload_result.get("files") or [] should_push = push if push is not None else settings.mcp_dev_auto_push push_result: dict[str, Any] | None = None if should_push and settings.gitea_token: push_result = await mcp_workspace.commit_and_push(server, commit_msg, written) await _emit( task_id, "mcp_dev", f"Git push {'ok' if push_result.get('ok') else 'failed'}: {push_result.get('commit') or push_result.get('error')}", {"server": server, "phase": "push", **push_result}, ) elif settings.gitea_token and written and task_id > 0: push_result = await queue_git_push_approval( task_id=task_id, server=server, written=written, commit_msg=commit_msg, summary=summary, emit=_emit, ) retry_result = await maybe_retry_tool_after_patch( task_id=task_id, retry_context=retry_context, manager=manager, emit=_emit, ) return { "ok": True, "summary": summary, "reload": reload_result, "push": push_result, "retry": retry_result, "tools": reload_result.get("tools"), }