diff --git a/dev/test_athena_operator.py b/dev/test_athena_operator.py index 52b36f9..17702d3 100644 --- a/dev/test_athena_operator.py +++ b/dev/test_athena_operator.py @@ -85,6 +85,45 @@ class OperatorTests(unittest.TestCase): with self.assertRaises(RuntimeError): self.module.execute({"ticket": proposal["ticket"], "confirmation": proposal["required_confirmation"]}) + def test_patch_update_is_compact_and_updates_both_trees(self): + before = self.module.sha((self.repo / "docs" / "test.md").read_bytes()) + proposal = self.module.prepare({ + "operation": "patch_update", + "payload": {"files": [{ + "path": "docs/test.md", "expected_sha256": before, + "patch": "--- a/docs/test.md\n+++ b/docs/test.md\n@@ -1 +1 @@\n-before\n+after\n", + }]}, + }) + self.assertIn("+after", proposal["preview"]) + result = self.module.execute({"ticket": proposal["ticket"], "confirmation": proposal["required_confirmation"]}) + self.assertEqual(result["operation"], "patch_update") + self.assertEqual((self.repo / "docs" / "test.md").read_text(), "after\n") + self.assertEqual((self.stack / "docs" / "test.md").read_text(), "after\n") + + def test_patch_update_rejects_wrong_context_and_drift(self): + before = self.module.sha((self.repo / "docs" / "test.md").read_bytes()) + with self.assertRaises(RuntimeError): + self.module.normalise_operation("patch_update", {"files": [{ + "path": "docs/test.md", "expected_sha256": before, + "patch": "@@ -1 +1 @@\n-wrong\n+after\n", + }]}) + with self.assertRaises(RuntimeError): + self.module.normalise_operation("patch_update", {"files": [{ + "path": "docs/test.md", "expected_sha256": "0" * 64, + "patch": "@@ -1 +1 @@\n-before\n+after\n", + }]}) + + def test_mcp_release_normalises_one_complete_workflow(self): + before = self.module.sha((self.repo / "docs" / "test.md").read_bytes()) + payload, preview = self.module.normalise_operation("mcp_release", { + "files": [{"path": "docs/test.md", "expected_sha256": before, "patch": "@@ -1 +1 @@\n-before\n+after\n"}], + "services": ["mcp-example"], "message": "Add example MCP service", + "checks": ["operator-tests"], "create_recovery": False, + }) + self.assertEqual(payload["services"], ["mcp-example"]) + self.assertEqual(payload["paths"], ["docs/test.md"]) + self.assertIn("ONE MCP RELEASE", preview) + def test_structured_power_operations_do_not_exist(self): self.assertNotIn("shell", self.module.ALLOWED_OPERATIONS) for operation in ("shutdown", "reboot", "ssh", "network", "command"): diff --git a/docs/CURRENT_REFERENCE.md b/docs/CURRENT_REFERENCE.md index f217fcd..d0272dd 100644 --- a/docs/CURRENT_REFERENCE.md +++ b/docs/CURRENT_REFERENCE.md @@ -358,6 +358,12 @@ sind serverseitig blockiert. Ein separater allgemeiner Shell-MCP wird nicht benötigt; die breite Fähigkeit ist portabel im Athena Operator auf VPN-Port 8202 enthalten. +Seit Operator 2.2 werden kleine Änderungen als SHA-geschützte Unified Diffs +über `patch_update` übertragen. `mcp_release` fasst den üblichen vollständigen +MCP-Ablauf in einem bestätigten Auftrag zusammen: Patch, Tests, benannter +Deploy, OpenWebUI-Sync, selektiver Git-Publish und Recovery. Damit muss das +Modell keine kompletten Compose- oder Installationsdateien rekonstruieren. + ## Bekannte Probleme des alten Hosts - Systempartition vollständig gefüllt diff --git a/docs/PLATFORM_CONTEXT_MCP.md b/docs/PLATFORM_CONTEXT_MCP.md index c4ba481..a065a41 100644 --- a/docs/PLATFORM_CONTEXT_MCP.md +++ b/docs/PLATFORM_CONTEXT_MCP.md @@ -91,16 +91,16 @@ anfordern oder kopieren. Der verbindliche Ablauf für dauerhafte Änderungen lautet: -1. `athena_operator_prepare` und nach separater Benutzerfreigabe - `athena_operator_execute` mit `file_update`; dies schreibt dieselben - ausgewählten Dateien driftgeschützt in den kanonischen Working Tree und die - ausgerollte Kopie. -2. Prüfungen über die Operation `run_checks` ausführen. -3. Nur betroffene Dienste über `compose_deploy` ausrollen. -4. Ausschließlich die ausdrücklich angegebenen geänderten Pfade mit - `git_publish` committen und pushen. Fremde Dirty-Worktree-Dateien bleiben - unberührt. -5. Mit `recovery` einen neuen Recovery-Koffer erzeugen und prüfen. +1. Kleine Änderungen mit `patch_update` als SHA-geschützten Unified Diff + vorbereiten. `file_update` ist neuen oder vollständig ersetzten Dateien + vorbehalten. +2. Für einen normalen MCP-Lifecycle bevorzugt ein einziges `mcp_release` + vorbereiten und nach separater Benutzerfreigabe ausführen. Es bündelt + Prüfungen, benannten Compose-Deploy, OpenWebUI-Sync, selektiven Git-Publish + und Recovery. +3. Einzeloperationen `run_checks`, `compose_deploy`, `git_publish` und + `recovery` nur für Diagnose oder bewusst partielle Wartung verwenden. + Fremde Dirty-Worktree-Dateien bleiben unberührt. Das allgemeine Terminal ist weder Ersatz für diesen Ablauf noch ein Weg zu Git-Schlüsseln. `/data/mike-ai-operator/repository` muss aus der diff --git a/docs/PLATFORM_OVERVIEW.md b/docs/PLATFORM_OVERVIEW.md index afa29c5..472f84d 100644 --- a/docs/PLATFORM_OVERVIEW.md +++ b/docs/PLATFORM_OVERVIEW.md @@ -166,5 +166,10 @@ geändert. Die Abweichung wird benannt und zuerst geklärt. /var/lib/docker/volumes Docker-Volumes, darunter OpenWebUI-Daten ``` +Kleine Quelländerungen erfolgen über `patch_update` statt als vollständiger +Dateiersatz. Ein normaler MCP-Release erfolgt über `mcp_release`, das den +versionierten Gesamtweg von Patch und Tests bis Deploy, Client-Sync, selektivem +Git-Publish und Recovery kapselt. + Secrets unter `/etc/mike-ai` werden ausschließlich verschlüsselt gesichert und gehören nie in Git, ein Wissensdokument oder einen Modellkontext. diff --git a/docs/QWEN_OPERATOR_CONTEXT.md b/docs/QWEN_OPERATOR_CONTEXT.md index f0e1be9..1890dde 100644 --- a/docs/QWEN_OPERATOR_CONTEXT.md +++ b/docs/QWEN_OPERATOR_CONTEXT.md @@ -103,8 +103,13 @@ Operator verwaltete Working Tree liegt unter Tree; `.mike-ai-source-commit` bezeichnet den ausgerollten Stand. Der offizielle GitHub-MCP ist strikt read-only und kann Gitea nicht pflegen. Für dauerhafte Änderungen ist ausschließlich der strukturierte Athena-Operator- -Arbeitsweg vorgesehen: `file_update` ändert driftgeschützt den kanonischen -Working Tree und die ausgerollte Kopie, `run_checks` prüft, `compose_deploy` +Arbeitsweg vorgesehen: `patch_update` ändert kleine Stellen als SHA-geschützten +Unified Diff im kanonischen Working Tree und in der ausgerollten Kopie; +`file_update` ist neuen oder vollständig ersetzten Dateien vorbehalten. Für +einen vollständigen MCP-Lifecycle bündelt `mcp_release` Patch, Tests, benannten +Deploy, OpenWebUI-Sync, selektiven Git-Publish und Recovery in einem bestätigten +Ablauf. Vollständige Compose-Dateien oder Base64-Kopien sind dafür unnötig. +`run_checks` prüft, `compose_deploy` rollt nur benannte Dienste aus, `git_publish` veröffentlicht nur ausdrücklich ausgewählte Pfade und `recovery` erneuert den Recovery-Koffer. Lege niemals einen zweiten Clone in der Sandbox an und fordere oder kopiere keinen diff --git a/platform/hermes/skills/athena-operator/SKILL.md b/platform/hermes/skills/athena-operator/SKILL.md index 1da1184..798d96d 100644 --- a/platform/hermes/skills/athena-operator/SKILL.md +++ b/platform/hermes/skills/athena-operator/SKILL.md @@ -65,7 +65,9 @@ repository and use live measurements only as evidence of current state. 5. **Change the source of truth.** Modify repository sources, not only a live container. Prefer `athena_operator_prepare`; show its full preview and stop for the exact user confirmation before `athena_operator_execute`. - Use `file_update` for the exact source paths. Never clone the repository in + Prefer `patch_update` with a small unified diff and the current file SHA for + ordinary edits. Use `file_update` only for new files or intentional complete + replacements. Never clone the repository in the Hermes sandbox and never request or copy an SSH key; the Operator owns the canonical worktree and its deploy credentials. Completion: the approved ticket matches the intended content. @@ -75,7 +77,11 @@ repository and use live measurements only as evidence of current state. 7. **Verify behavior.** Run syntax/config checks, focused tests, service health, and one bounded functional test. A running container alone is not proof. Completion: expected behavior and rollback path are both verified. -8. **Close the maintenance loop.** Update relevant docs, publish only the +8. **Close the maintenance loop.** For a normal MCP delivery, prefer one + confirmed `mcp_release`; it applies the reviewed patches, runs checks, + deploys only named services, synchronizes OpenWebUI, publishes selected + paths and creates recovery. Use separate operations only for diagnosis or a + deliberately partial workflow. Otherwise update relevant docs, publish only the explicitly selected changed paths with `git_publish`, create a newer recovery bundle, then check maintenance status. Completion: source commit, deployed state, docs, and recovery agree. @@ -106,8 +112,9 @@ repository and use live measurements only as evidence of current state. - A profile switch can terminate active generation and invalidate prompt cache. - A healthy container can still expose the wrong model, route, or tool set. - `/opt/mike-ai/stack` is deployed source, not automatically the canonical Git - worktree. Complete durable changes through Operator operations `file_update`, - `run_checks`, `compose_deploy`, `git_publish`, and `recovery`. + worktree. Use `patch_update` for compact edits and `mcp_release` for the + complete MCP lifecycle. Do not reconstruct whole Compose or installer files + for a small change. - New skills are loaded at the next Hermes session; absence in the current session is expected. diff --git a/platform/mcp/athena_operator_mcp.py b/platform/mcp/athena_operator_mcp.py index 7a3b7a3..b316583 100755 --- a/platform/mcp/athena_operator_mcp.py +++ b/platform/mcp/athena_operator_mcp.py @@ -10,7 +10,7 @@ import sys from typing import Any -VERSION = "2.0.0" +VERSION = "2.2.0" SOCKET_PATH = os.environ.get("ATHENA_OPERATOR_SOCKET", "/operator/operator.sock") if hasattr(sys.stdin, "reconfigure"): @@ -84,12 +84,18 @@ TOOLS = [ "description": ( "PREPARE a state-changing Athena operation. This never changes state. It returns a " "content-bound ticket, exact preview and confirmation phrase. Supported operations: " - "file_update (write repository and deployed stack files), run_checks, compose_deploy, " + "patch_update (preferred for small changes), file_update (only for new or fully replaced files), " + "mcp_release (preferred one-ticket end-to-end MCP delivery), run_checks, compose_deploy, " "container_action, openwebui_sync, git_publish, model_download, benchmark, recovery. Show the complete " "preview to the user and stop. Never execute in the same autonomous tool sequence. This is the " "supported durable source and Git path: never clone the repository inside a sandbox and never " "request or copy an SSH key. " - "file_update payload: {files:[{path,content,expected_sha256?}]}; run_checks: " + "patch_update payload: {files:[{path,patch,expected_sha256?}]} using standard unified diffs; " + "file_update payload: {files:[{path,content,expected_sha256?}]}; " + "mcp_release payload: {files:[patch entries],services,checks?,message,paths?,build?," + "openwebui_sync?,create_recovery?,recovery_label?}. It applies drift-protected patches, runs checks, " + "deploys only named MCP services, syncs OpenWebUI, publishes only selected paths and creates recovery. " + "Use mcp_release instead of manually chaining all those operations. run_checks: " "{checks:[operator-tests,openwebui-filter-tests,platform-verify,compose-main,compose-mcp]}; " "compose_deploy: {compose_file,services,build}; container_action: {action,containers}; " "openwebui_sync: {}; " @@ -99,7 +105,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { - "operation": {"type": "string", "enum": ["file_update", "run_checks", "compose_deploy", "container_action", "openwebui_sync", "git_publish", "model_download", "benchmark", "recovery"]}, + "operation": {"type": "string", "enum": ["patch_update", "file_update", "mcp_release", "run_checks", "compose_deploy", "container_action", "openwebui_sync", "git_publish", "model_download", "benchmark", "recovery"]}, "payload": {"type": "object"}, }, "required": ["operation", "payload"], "additionalProperties": False, diff --git a/platform/mcp/compose.yaml b/platform/mcp/compose.yaml index 2a337cb..b46d9b2 100644 --- a/platform/mcp/compose.yaml +++ b/platform/mcp/compose.yaml @@ -191,7 +191,7 @@ services: build: context: . dockerfile: Dockerfile.athena-operator - image: mike-ai/mcp-athena-operator:2.1.0 + image: mike-ai/mcp-athena-operator:2.2.0 container_name: mike-ai-mcp-athena-operator environment: ATHENA_OPERATOR_SOCKET: /operator/operator.sock diff --git a/platform/operator/athena_operatord.py b/platform/operator/athena_operatord.py index 57ae0bc..473a762 100755 --- a/platform/operator/athena_operatord.py +++ b/platform/operator/athena_operatord.py @@ -28,7 +28,7 @@ from pathlib import Path from typing import Any -VERSION = "2.0.0" +VERSION = "2.2.0" STACK = Path(os.environ.get("ATHENA_OPERATOR_STACK", "/opt/mike-ai/stack")).resolve() REPOSITORY = Path(os.environ.get("ATHENA_OPERATOR_REPOSITORY", "/data/mike-ai-operator/repository")).resolve() STATE = Path(os.environ.get("ATHENA_OPERATOR_STATE", "/data/mike-ai-operator/state")).resolve() @@ -48,9 +48,11 @@ PROTECTED_CONTAINERS = { "mike-ai-wireguard-gateway", } ALLOWED_OPERATIONS = { - "file_update", "run_checks", "compose_deploy", "container_action", + "file_update", "patch_update", "mcp_release", "run_checks", "compose_deploy", "container_action", "openwebui_sync", "git_publish", "model_download", "benchmark", "recovery", } + +HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") ALLOWED_CHECKS = { "operator-tests": ["python3", "dev/test_athena_operator.py"], "openwebui-filter-tests": ["python3", "dev/test_openwebui_filters.py"], @@ -134,6 +136,85 @@ def sha(data: bytes) -> str: return hashlib.sha256(data).hexdigest() +def apply_unified_patch(before: str, patch: str) -> str: + """Apply one ordinary unified diff without invoking a shell command.""" + if not patch or len(patch.encode()) > MAX_FILE_BYTES: + raise ValueError("patch is empty or exceeds limit") + source = before.splitlines(keepends=True) + lines = patch.splitlines(keepends=True) + position = 0 + output: list[str] = [] + index = 0 + if index < len(lines) and lines[index].startswith("--- "): + index += 1 + if index < len(lines) and lines[index].startswith("+++ "): + index += 1 + hunks = 0 + while index < len(lines): + header = lines[index].rstrip("\r\n") + match = HUNK_HEADER.match(header) + if not match: + raise ValueError("patch must contain only standard unified-diff hunks") + old_start = int(match.group(1)) + old_count = int(match.group(2) or "1") + new_count = int(match.group(4) or "1") + target = max(0, old_start - 1) + if target < position or target > len(source): + raise RuntimeError("patch hunk is outside the source file") + output.extend(source[position:target]) + position = target + consumed = produced = 0 + index += 1 + while index < len(lines) and not lines[index].startswith("@@ "): + line = lines[index] + if line.startswith("\\ No newline at end of file"): + index += 1 + continue + if not line or line[0] not in " +-": + raise ValueError("invalid unified-diff line") + marker, value = line[0], line[1:] + if marker in " -": + if position >= len(source) or source[position] != value: + raise RuntimeError("patch context does not match source") + if marker == " ": + output.append(source[position]); produced += 1 + position += 1; consumed += 1 + else: + output.append(value); produced += 1 + index += 1 + if consumed != old_count or produced != new_count: + raise RuntimeError("patch hunk line counts do not match its header") + hunks += 1 + if not hunks: + raise ValueError("patch contains no hunks") + output.extend(source[position:]) + return "".join(output) + + +def normalise_patches(files: Any) -> tuple[list[dict[str, Any]], str]: + if not isinstance(files, list) or not 1 <= len(files) <= MAX_FILES: + raise ValueError("files must contain 1..24 patch entries") + normal: list[dict[str, Any]] = [] + previews: list[str] = [] + for item in files: + if not isinstance(item, dict): + raise ValueError("each patch entry must be an object") + relative = safe_relative(str(item.get("path", ""))) + target = source_file(REPOSITORY, relative) + before = target.read_text(encoding="utf-8", errors="strict") if target.is_file() else "" + before_sha = sha(before.encode()) + expected = str(item.get("expected_sha256", "")) + if expected and expected != before_sha: + raise RuntimeError(f"source drift for {relative}") + patch = str(item.get("patch", "")) + after = apply_unified_patch(before, patch) + if len(after.encode()) > MAX_FILE_BYTES: + raise ValueError("patched file exceeds limit") + normal.append({"path": str(relative), "content": after, "before_sha256": before_sha, "after_sha256": sha(after.encode())}) + previews.append(f"### {relative}\n{compact(patch)}") + return normal, "\n\n".join(previews) + + def json_write(path: Path, value: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") @@ -277,6 +358,44 @@ def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[s normal.append({"path": str(relative), "content": content, "before_sha256": before_sha, "after_sha256": sha(raw)}) previews.append(compact(diff)) return {"files": normal}, "\n".join(previews) + if operation == "patch_update": + files, preview = normalise_patches(payload.get("files")) + return {"files": files}, preview + if operation == "mcp_release": + files, patch_preview = normalise_patches(payload.get("files")) + checks = payload.get("checks") or ["operator-tests", "compose-mcp"] + if not isinstance(checks, list) or not checks or any(name not in ALLOWED_CHECKS for name in checks): + raise ValueError("unknown release check suite") + compose_file = str(payload.get("compose_file", "platform/mcp/compose.yaml")) + if compose_file != "platform/mcp/compose.yaml": + raise ValueError("MCP releases must use platform/mcp/compose.yaml") + services = payload.get("services") or [] + if not isinstance(services, list) or not 1 <= len(services) <= 12 or any(not SAFE_NAME.fullmatch(str(x)) for x in services): + raise ValueError("invalid MCP service list") + message = str(payload.get("message", "")) + if not SAFE_COMMIT.fullmatch(message): + raise ValueError("invalid commit message") + paths = payload.get("paths") or [item["path"] for item in files] + selected = [str(safe_relative(str(path))) for path in paths] + if len(set(selected)) != len(selected) or not set(item["path"] for item in files).issubset(set(selected)): + raise ValueError("release paths must be unique and include every patched file") + label = str(payload.get("recovery_label", time.strftime("%Y%m%d-%H%M"))) + if not SAFE_NAME.fullmatch(label): + raise ValueError("invalid recovery label") + normal = { + "files": files, "checks": checks, "compose_file": compose_file, + "services": [str(x) for x in services], "build": bool(payload.get("build", True)), + "openwebui_sync": bool(payload.get("openwebui_sync", True)), + "message": message, "paths": selected, + "create_recovery": bool(payload.get("create_recovery", True)), "recovery_label": label, + } + preview = ( + f"ONE MCP RELEASE\nServices: {', '.join(normal['services'])}\n" + f"Checks: {', '.join(checks)}\nOpenWebUI sync: {normal['openwebui_sync']}\n" + f"Selective commit: {message}\nPaths: {', '.join(selected)}\n" + f"Recovery: {normal['create_recovery']} ({label})\n\n{patch_preview}" + ) + return normal, preview if operation == "run_checks": checks = payload.get("checks") or [] if not isinstance(checks, list) or not checks or any(name not in ALLOWED_CHECKS for name in checks): @@ -390,18 +509,107 @@ def start_job(ticket: str, operation: str, worker) -> dict[str, Any]: return {"job_id": job_id, "status": "running", "instruction": "Poll athena_operator_job until completed or failed."} +def apply_files(files: list[dict[str, Any]], backup: Path) -> list[str]: + for item in files: + relative = safe_relative(item["path"]) + current = source_file(REPOSITORY, relative) + current_sha = sha(current.read_bytes()) if current.is_file() else sha(b"") + if current_sha != item["before_sha256"]: + raise RuntimeError(f"source drift after preview: {relative}") + for item in files: + sync_file(safe_relative(item["path"]), item["content"], backup) + return [item["path"] for item in files] + + +def restore_files(files: list[dict[str, Any]], backup: Path) -> None: + for item in files: + relative = safe_relative(item["path"]) + for root in (REPOSITORY, STACK): + target = source_file(root, relative) + saved = backup / root.name / relative + if saved.is_file(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(saved, target) + elif target.exists(): + target.unlink() + + +def publish_paths(message: str, selected: list[str]) -> dict[str, Any]: + run(["git", "add", "--", *selected], cwd=REPOSITORY, check=True) + run(["git", "diff", "--cached", "--check", "--", *selected], cwd=REPOSITORY, check=True) + commit = run(["git", "commit", "-m", message, "--", *selected], cwd=REPOSITORY, check=True) + pushed = run(["git", "push", "origin", "HEAD:main"], cwd=REPOSITORY, timeout=300, check=True) + head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip() + (STACK / ".mike-ai-source-commit").write_text(head + "\n") + return {"commit": head, "commit_output": commit, "push_output": pushed} + + +def perform_recovery(label: str) -> dict[str, Any]: + dirty = run(["git", "status", "--porcelain"], cwd=REPOSITORY, check=True)["output"].strip() + if dirty: + raise RuntimeError("publish repository changes before creating a recovery kit") + head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip() + marker = (STACK / ".mike-ai-source-commit").read_text().strip() + if marker != head: + raise RuntimeError("deployed source marker and operator repository HEAD differ") + encrypted = Path(f"/data/athena-recovery-{label}.tar.age") + source_bundle = Path(f"/data/athena-source-{label}.git.bundle") + release = Path(f"/data/mike-ai-recovery-kit-{label}") + for target in (encrypted, source_bundle, release): + if target.exists(): + raise FileExistsError(target) + git_bundle = run(["git", "bundle", "create", str(source_bundle), "--all"], cwd=REPOSITORY, timeout=1800, check=True) + encrypted_result = run([str(STACK / "platform/recovery/create-recovery-bundle.sh"), str(encrypted)], timeout=7200, check=True) + identity = Path("/data/mike-ai-recovery-kit/recovery.agekey") + if not identity.is_file(): + raise RuntimeError("existing recovery identity is unavailable") + kit_result = run([str(STACK / "platform/recovery/create-self-contained-data-kit.sh"), str(encrypted), str(identity), str(source_bundle), str(release)], timeout=7200, check=True) + verify = run(["sha256sum", "-c", "SHA256SUMS"], cwd=release, timeout=1800, check=True) + return {"commit": head, "recovery_bundle": str(encrypted), "source_bundle": str(source_bundle), "self_contained_kit": str(release), "git_bundle": git_bundle, "encrypted_bundle": encrypted_result, "kit": kit_result, "verification": verify} + + def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> dict[str, Any]: - if operation == "file_update": + if operation in {"file_update", "patch_update"}: backup = STATE / "backups" / f"{now()}-{ticket}" - for item in payload["files"]: - relative = safe_relative(item["path"]) - current = source_file(REPOSITORY, relative) - current_sha = sha(current.read_bytes()) if current.is_file() else sha(b"") - if current_sha != item["before_sha256"]: - raise RuntimeError(f"source drift after preview: {relative}") - for item in payload["files"]: - sync_file(safe_relative(item["path"]), item["content"], backup) - return {"changed": [item["path"] for item in payload["files"]], "backup": str(backup), "git_diff": run(["git", "diff", "--stat"], cwd=REPOSITORY)} + changed = apply_files(payload["files"], backup) + return {"changed": changed, "backup": str(backup), "git_diff": run(["git", "diff", "--stat"], cwd=REPOSITORY)} + if operation == "mcp_release": + def release(): + backup = STATE / "backups" / f"{now()}-{ticket}" + published = False + deployed = False + synced = False + try: + changed = apply_files(payload["files"], backup) + checks = [] + for name in payload["checks"]: + result = run(ALLOWED_CHECKS[name], cwd=REPOSITORY, timeout=1200, check=True) + checks.append({"name": name, **result}) + run(["docker", "compose", "-f", payload["compose_file"], "config", "-q"], cwd=STACK, check=True) + argv = ["docker", "compose", "-f", payload["compose_file"], "up", "-d"] + if payload["build"]: argv.append("--build") + argv.extend(payload["services"]) + deploy = run(argv, cwd=STACK, timeout=3600, check=True) + deployed = True + sync = None + if payload["openwebui_sync"]: + sync = run(["bash", str(STACK / "platform/openwebui/install-filters.sh")], cwd=STACK, timeout=1800, check=True) + synced = True + publication = publish_paths(payload["message"], payload["paths"]) + published = True + recovery = perform_recovery(payload["recovery_label"]) if payload["create_recovery"] else None + return {"changed": changed, "checks": checks, "deploy": deploy, "openwebui_sync": sync, "publication": publication, "recovery": recovery, "containers": run(["docker", "ps", "--format", "{{.Names}}\t{{.Status}}"])} + except Exception: + if not published: + restore_files(payload["files"], backup) + run(["git", "reset", "--", *payload["paths"]], cwd=REPOSITORY) + if deployed: + rollback_argv = ["docker", "compose", "-f", payload["compose_file"], "up", "-d", "--build", *payload["services"]] + run(rollback_argv, cwd=STACK, timeout=3600) + if synced: + run(["bash", str(STACK / "platform/openwebui/install-filters.sh")], cwd=STACK, timeout=1800) + raise + return start_job(ticket, operation, release) if operation == "run_checks": return {"checks": [{"name": name, **run(ALLOWED_CHECKS[name], cwd=REPOSITORY, timeout=1200)} for name in payload["checks"]]} if operation == "compose_deploy": @@ -424,13 +632,7 @@ def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> d current_status = run(["git", "status", "--short", "--", *selected], cwd=REPOSITORY, check=True)["output"].strip() if current_status != payload["reviewed_status"]: raise RuntimeError("selected repository paths changed after the Git publish preview") - run(["git", "add", "--", *selected], cwd=REPOSITORY, check=True) - run(["git", "diff", "--cached", "--check", "--", *selected], cwd=REPOSITORY, check=True) - commit = run(["git", "commit", "-m", payload["message"], "--", *selected], cwd=REPOSITORY, check=True) - pushed = run(["git", "push", "origin", "HEAD:main"], cwd=REPOSITORY, timeout=300, check=True) - head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip() - (STACK / ".mike-ai-source-commit").write_text(head + "\n") - return {"commit": head, "commit_output": commit, "push_output": pushed} + return publish_paths(payload["message"], selected) if operation == "model_download": def download(): destination = source_file(MODELS, Path(payload["destination"])) @@ -456,40 +658,7 @@ def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> d return start_job(ticket, operation, benchmark) if operation == "recovery": def recovery(): - label = payload["label"] - dirty = run(["git", "status", "--porcelain"], cwd=REPOSITORY, check=True)["output"].strip() - if dirty: - raise RuntimeError("publish repository changes before creating a recovery kit") - head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip() - marker = (STACK / ".mike-ai-source-commit").read_text().strip() - if marker != head: - raise RuntimeError("deployed source marker and operator repository HEAD differ") - encrypted = Path(f"/data/athena-recovery-{label}.tar.age") - source_bundle = Path(f"/data/athena-source-{label}.git.bundle") - release = Path(f"/data/mike-ai-recovery-kit-{label}") - for target in (encrypted, source_bundle, release): - if target.exists(): - raise FileExistsError(target) - git_bundle = run(["git", "bundle", "create", str(source_bundle), "--all"], cwd=REPOSITORY, timeout=1800, check=True) - encrypted_result = run([str(STACK / "platform/recovery/create-recovery-bundle.sh"), str(encrypted)], timeout=7200, check=True) - identity = Path("/data/mike-ai-recovery-kit/recovery.agekey") - if not identity.is_file(): - raise RuntimeError("existing recovery identity is unavailable") - kit_result = run([ - str(STACK / "platform/recovery/create-self-contained-data-kit.sh"), - str(encrypted), str(identity), str(source_bundle), str(release), - ], timeout=7200, check=True) - verify = run(["sha256sum", "-c", "SHA256SUMS"], cwd=release, timeout=1800, check=True) - return { - "commit": head, - "recovery_bundle": str(encrypted), - "source_bundle": str(source_bundle), - "self_contained_kit": str(release), - "git_bundle": git_bundle, - "encrypted_bundle": encrypted_result, - "kit": kit_result, - "verification": verify, - } + return perform_recovery(payload["label"]) return start_job(ticket, operation, recovery) raise AssertionError(operation)