| 1 | #!/usr/bin/env python3 |
| 2 | from __future__ import annotations |
| 3 | |
| 4 | import argparse |
| 5 | import importlib.util |
| 6 | import json |
| 7 | import os |
| 8 | import re |
| 9 | import shutil |
| 10 | import signal |
| 11 | import stat |
| 12 | import subprocess |
| 13 | import sys |
| 14 | import tempfile |
| 15 | import time |
| 16 | import urllib.error |
| 17 | import urllib.request |
| 18 | import zipfile |
| 19 | from datetime import UTC, datetime |
| 20 | from pathlib import Path |
| 21 | from typing import Any |
| 22 | |
| 23 | import yaml |
| 24 | |
| 25 | |
| 26 | OFFICIAL_REPO_URL = os.environ.get( |
| 27 | "A0_SELF_UPDATE_REMOTE_URL", |
| 28 | "https://github.com/agent0ai/agent-zero.git", |
| 29 | ) |
| 30 | REPO_DIR = Path("/a0") |
| 31 | TRIGGER_FILE = Path("/exe/a0-self-update.yaml") |
| 32 | STATUS_FILE = Path("/exe/a0-self-update-status.yaml") |
| 33 | LOG_FILE = Path("/exe/a0-self-update.log") |
| 34 | DEFAULT_HEALTH_URL = os.environ.get( |
| 35 | "A0_SELF_UPDATE_HEALTH_URL", |
| 36 | "http://127.0.0.1:80/api/health", |
| 37 | ) |
| 38 | DEFAULT_HEALTH_TIMEOUT_SECONDS = int( |
| 39 | os.environ.get("A0_SELF_UPDATE_HEALTH_TIMEOUT_SECONDS", "180") |
| 40 | ) |
| 41 | DEFAULT_HEALTH_POLL_INTERVAL_SECONDS = float( |
| 42 | os.environ.get("A0_SELF_UPDATE_HEALTH_POLL_INTERVAL_SECONDS", "2") |
| 43 | ) |
| 44 | DEFAULT_BACKUP_DIR = "/root/update-backups" |
| 45 | DEFAULT_BACKUP_CONFLICT_POLICY = "rename" |
| 46 | BACKUP_CONFLICT_POLICIES = {"rename", "overwrite", "fail"} |
| 47 | MIN_SELECTOR_VERSION = (1, 0) |
| 48 | LATEST_SELECTOR_TAG = "latest" |
| 49 | DESKTOP_PROFILE_STATE_RELATIVE_DIRS = ( |
| 50 | Path("usr/plugins/_desktop/profiles"), |
| 51 | Path("usr/_desktop/profiles"), |
| 52 | Path("tmp/_office/desktop/profiles"), |
| 53 | ) |
| 54 | |
| 55 | |
| 56 | def now_iso() -> str: |
| 57 | return datetime.now(UTC).isoformat().replace("+00:00", "Z") |
| 58 | |
| 59 | |
| 60 | class AttemptLogger: |
| 61 | def __init__(self, path: Path): |
| 62 | self.path = path |
| 63 | |
| 64 | def reset(self) -> None: |
| 65 | self.path.parent.mkdir(parents=True, exist_ok=True) |
| 66 | self.path.write_text("", encoding="utf-8") |
| 67 | |
| 68 | def log(self, message: str = "") -> None: |
| 69 | line = f"[{now_iso()}] {message}".rstrip() |
| 70 | print(f"[a0-self-update] {message}", flush=True) |
| 71 | with self.path.open("a", encoding="utf-8") as handle: |
| 72 | handle.write(line + "\n") |
| 73 | |
| 74 | def log_block(self, title: str, content: str) -> None: |
| 75 | cleaned = content.rstrip() |
| 76 | self.log(f"{title}:") |
| 77 | if not cleaned: |
| 78 | self.log("(empty)") |
| 79 | return |
| 80 | with self.path.open("a", encoding="utf-8") as handle: |
| 81 | for line in cleaned.splitlines(): |
| 82 | handle.write(f" {line}\n") |
| 83 | |
| 84 | |
| 85 | class NullLogger: |
| 86 | def reset(self) -> None: |
| 87 | return |
| 88 | |
| 89 | def log(self, message: str = "") -> None: |
| 90 | return |
| 91 | |
| 92 | def log_block(self, title: str, content: str) -> None: |
| 93 | return |
| 94 | |
| 95 | |
| 96 | def load_yaml(path: Path) -> dict[str, Any] | None: |
| 97 | if not path.exists(): |
| 98 | return None |
| 99 | loaded = yaml.safe_load(path.read_text(encoding="utf-8")) |
| 100 | return loaded if isinstance(loaded, dict) else None |
| 101 | |
| 102 | |
| 103 | def write_yaml(path: Path, payload: dict[str, Any]) -> None: |
| 104 | path.parent.mkdir(parents=True, exist_ok=True) |
| 105 | path.write_text( |
| 106 | yaml.safe_dump(payload, allow_unicode=True, sort_keys=False), |
| 107 | encoding="utf-8", |
| 108 | ) |
| 109 | |
| 110 | |
| 111 | def write_status(payload: dict[str, Any]) -> None: |
| 112 | write_yaml(STATUS_FILE, payload) |
| 113 | |
| 114 | |
| 115 | def git_output(repo_dir: Path, *args: str) -> str: |
| 116 | completed = subprocess.run( |
| 117 | ["git", "-C", str(repo_dir), *args], |
| 118 | check=True, |
| 119 | text=True, |
| 120 | capture_output=True, |
| 121 | env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, |
| 122 | ) |
| 123 | return completed.stdout.strip() |
| 124 | |
| 125 | |
| 126 | def normalize_describe_to_version(describe: str) -> str: |
| 127 | match = re.fullmatch(r"(.+)-\d+-g[0-9a-f]+", describe) |
| 128 | if match: |
| 129 | return match.group(1) |
| 130 | return describe |
| 131 | |
| 132 | |
| 133 | def split_describe_version(describe: str) -> tuple[str, int]: |
| 134 | normalized = describe.strip() |
| 135 | match = re.fullmatch(r"(.+)-(\d+)-g[0-9a-f]+", normalized) |
| 136 | if not match: |
| 137 | return normalized, 0 |
| 138 | return match.group(1), int(match.group(2)) |
| 139 | |
| 140 | |
| 141 | def parse_selector_version(tag: str) -> tuple[int, int] | None: |
| 142 | match = re.fullmatch(r"v(\d+)\.(\d+)", tag.strip()) |
| 143 | if not match: |
| 144 | return None |
| 145 | return int(match.group(1)), int(match.group(2)) |
| 146 | |
| 147 | |
| 148 | def is_valid_selector_tag(tag: str) -> bool: |
| 149 | return parse_selector_version(tag) is not None |
| 150 | |
| 151 | |
| 152 | def is_supported_selector_tag(tag: str) -> bool: |
| 153 | parsed = parse_selector_version(tag) |
| 154 | return parsed is not None and parsed >= MIN_SELECTOR_VERSION |
| 155 | |
| 156 | |
| 157 | def sort_selector_supported_tags(tags: list[str]) -> list[str]: |
| 158 | return sorted( |
| 159 | tags, |
| 160 | key=lambda tag: parse_selector_version(tag) or (-1, -1), |
| 161 | reverse=True, |
| 162 | ) |
| 163 | |
| 164 | |
| 165 | def parse_major_version(tag: str) -> int | None: |
| 166 | match = re.fullmatch(r"v(\d+)(?:[.-].*)?", tag.strip()) |
| 167 | if not match: |
| 168 | return None |
| 169 | return int(match.group(1)) |
| 170 | |
| 171 | |
| 172 | def is_latest_selector_tag(tag: str) -> bool: |
| 173 | return tag.strip().lower() == LATEST_SELECTOR_TAG |
| 174 | |
| 175 | |
| 176 | def get_tag_commit_ref(tag: str) -> str: |
| 177 | return f"refs/tags/{tag}^{{commit}}" |
| 178 | |
| 179 | |
| 180 | def build_default_backup_name() -> str: |
| 181 | timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") |
| 182 | return f"usr-{timestamp}.zip" |
| 183 | |
| 184 | |
| 185 | def normalize_requested_tag(tag: str) -> str: |
| 186 | normalized = (tag or "").strip() |
| 187 | if not normalized: |
| 188 | return LATEST_SELECTOR_TAG |
| 189 | if is_latest_selector_tag(normalized): |
| 190 | return LATEST_SELECTOR_TAG |
| 191 | if not is_valid_selector_tag(normalized): |
| 192 | raise ValueError("Release tag must use the format vX.Y.") |
| 193 | if not is_supported_selector_tag(normalized): |
| 194 | raise ValueError("Release tag must be v1.0 or newer.") |
| 195 | return normalized |
| 196 | |
| 197 | |
| 198 | def normalize_backup_conflict_policy(conflict_policy: str) -> str: |
| 199 | normalized = (conflict_policy or DEFAULT_BACKUP_CONFLICT_POLICY).strip().lower() |
| 200 | if normalized not in BACKUP_CONFLICT_POLICIES: |
| 201 | raise ValueError("Backup conflict policy must be one of: rename, overwrite, fail.") |
| 202 | return normalized |
| 203 | |
| 204 | |
| 205 | def get_latest_same_major_tag( |
| 206 | repo_dir: Path, |
| 207 | *, |
| 208 | branch_ref: str, |
| 209 | current_version: str, |
| 210 | ) -> str: |
| 211 | current_major = parse_major_version(current_version) |
| 212 | if current_major is None: |
| 213 | raise RuntimeError( |
| 214 | f"Could not determine the installed major version from {current_version}. " |
| 215 | "Use an explicit tag instead of latest." |
| 216 | ) |
| 217 | |
| 218 | output = git_output(repo_dir, "tag", "--merged", branch_ref) |
| 219 | same_major_tags = [ |
| 220 | tag |
| 221 | for tag in (line.strip() for line in output.splitlines()) |
| 222 | if is_supported_selector_tag(tag) and parse_major_version(tag) == current_major |
| 223 | ] |
| 224 | if not same_major_tags: |
| 225 | raise RuntimeError( |
| 226 | f"No v{current_major}.x release tags are reachable from branch " |
| 227 | f"{branch_ref.rsplit('/', 1)[-1]}." |
| 228 | ) |
| 229 | return sort_selector_supported_tags(same_major_tags)[0] |
| 230 | |
| 231 | |
| 232 | def ensure_latest_target_matches_current_major( |
| 233 | *, |
| 234 | branch: str, |
| 235 | current_version: str, |
| 236 | target_version: str, |
| 237 | ) -> None: |
| 238 | current_major = parse_major_version(current_version) |
| 239 | if current_major is None: |
| 240 | raise RuntimeError( |
| 241 | f"Could not determine the installed major version from {current_version}. " |
| 242 | "Use an explicit tag instead of latest." |
| 243 | ) |
| 244 | |
| 245 | target_major = parse_major_version(target_version) |
| 246 | if target_major is None or not is_supported_selector_tag(target_version): |
| 247 | raise RuntimeError( |
| 248 | f"Could not resolve latest on branch {branch} to a supported vX.Y release. " |
| 249 | "Use an explicit tag instead." |
| 250 | ) |
| 251 | |
| 252 | if target_major != current_major: |
| 253 | raise RuntimeError( |
| 254 | f"Latest on branch {branch} resolves to {target_version}, but the installed " |
| 255 | f"version is {current_version}. Use an explicit tag to change major versions." |
| 256 | ) |
| 257 | |
| 258 | |
| 259 | def get_repo_version_info(repo_dir: Path) -> dict[str, str]: |
| 260 | describe = git_output(repo_dir, "describe", "--tags", "--always") |
| 261 | commit = git_output(repo_dir, "rev-parse", "HEAD") |
| 262 | branch = git_optional_output(repo_dir, "branch", "--show-current") |
| 263 | return { |
| 264 | "branch": branch, |
| 265 | "describe": describe, |
| 266 | "short_tag": normalize_describe_to_version(describe), |
| 267 | "commit": commit, |
| 268 | "short_commit": commit[:7], |
| 269 | } |
| 270 | |
| 271 | |
| 272 | def git_optional_output(repo_dir: Path, *args: str) -> str: |
| 273 | completed = subprocess.run( |
| 274 | ["git", "-C", str(repo_dir), *args], |
| 275 | check=False, |
| 276 | text=True, |
| 277 | capture_output=True, |
| 278 | env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, |
| 279 | ) |
| 280 | if completed.returncode != 0: |
| 281 | return "" |
| 282 | return completed.stdout.strip() |
| 283 | |
| 284 | |
| 285 | def remove_path(path: Path) -> None: |
| 286 | if path.is_symlink() or path.is_file(): |
| 287 | path.unlink(missing_ok=True) |
| 288 | return |
| 289 | if path.exists(): |
| 290 | shutil.rmtree(path) |
| 291 | |
| 292 | |
| 293 | def get_repo_relative_path(repo_dir: Path, path: Path) -> str | None: |
| 294 | try: |
| 295 | return path.resolve().relative_to(repo_dir.resolve()).as_posix() |
| 296 | except ValueError: |
| 297 | return None |
| 298 | |
| 299 | |
| 300 | def sanitize_filename(name: str, default_name: str) -> str: |
| 301 | raw = (name or "").strip() |
| 302 | if not raw: |
| 303 | raw = default_name |
| 304 | raw = Path(raw).name |
| 305 | raw = re.sub(r"[^A-Za-z0-9._-]+", "-", raw).strip(".-") or default_name |
| 306 | if not raw.lower().endswith(".zip"): |
| 307 | raw = f"{raw}.zip" |
| 308 | return raw |
| 309 | |
| 310 | |
| 311 | def resolve_backup_destination( |
| 312 | directory: Path, |
| 313 | filename: str, |
| 314 | conflict_policy: str, |
| 315 | ) -> Path: |
| 316 | normalized_policy = conflict_policy.strip().lower() |
| 317 | directory.mkdir(parents=True, exist_ok=True) |
| 318 | destination = directory / filename |
| 319 | if not destination.exists(): |
| 320 | return destination |
| 321 | |
| 322 | if normalized_policy == "overwrite": |
| 323 | remove_path(destination) |
| 324 | return destination |
| 325 | if normalized_policy == "fail": |
| 326 | raise FileExistsError(f"Backup file already exists: {destination}") |
| 327 | if normalized_policy != "rename": |
| 328 | raise ValueError("backup_conflict_policy must be rename, overwrite, or fail.") |
| 329 | |
| 330 | stem = destination.stem |
| 331 | suffix = destination.suffix |
| 332 | index = 2 |
| 333 | while True: |
| 334 | candidate = directory / f"{stem}-{index}{suffix}" |
| 335 | if not candidate.exists(): |
| 336 | return candidate |
| 337 | index += 1 |
| 338 | |
| 339 | |
| 340 | def create_usr_backup( |
| 341 | *, |
| 342 | repo_dir: Path, |
| 343 | backup_path: str, |
| 344 | backup_name: str, |
| 345 | conflict_policy: str, |
| 346 | logger: AttemptLogger, |
| 347 | ) -> Path: |
| 348 | usr_dir = repo_dir / "usr" |
| 349 | if not usr_dir.exists(): |
| 350 | raise FileNotFoundError(f"User directory not found: {usr_dir}") |
| 351 | |
| 352 | destination_dir = Path(backup_path) |
| 353 | if not destination_dir.is_absolute(): |
| 354 | destination_dir = (repo_dir / destination_dir).resolve() |
| 355 | else: |
| 356 | destination_dir = destination_dir.resolve() |
| 357 | destination_name = sanitize_filename(backup_name, "agent-zero-usr-backup.zip") |
| 358 | destination = resolve_backup_destination(destination_dir, destination_name, conflict_policy) |
| 359 | |
| 360 | temp_fd, temp_path = tempfile.mkstemp(suffix=".zip") |
| 361 | os.close(temp_fd) |
| 362 | temporary_backup = Path(temp_path) |
| 363 | |
| 364 | try: |
| 365 | with zipfile.ZipFile( |
| 366 | temporary_backup, |
| 367 | "w", |
| 368 | compression=zipfile.ZIP_DEFLATED, |
| 369 | compresslevel=6, |
| 370 | ) as archive: |
| 371 | for root, dirs, files in os.walk(usr_dir): |
| 372 | root_path = Path(root) |
| 373 | root_relative = root_path.relative_to(usr_dir) |
| 374 | dirs[:] = [ |
| 375 | dirname |
| 376 | for dirname in dirs |
| 377 | if not should_exclude_from_usr_backup( |
| 378 | root_relative / dirname, |
| 379 | logger, |
| 380 | ) |
| 381 | ] |
| 382 | for filename in files: |
| 383 | source_file = root_path / filename |
| 384 | if not should_include_usr_backup_entry(source_file, logger): |
| 385 | continue |
| 386 | archive_name = Path("usr") / source_file.relative_to(usr_dir) |
| 387 | try: |
| 388 | archive.write(source_file, archive_name.as_posix()) |
| 389 | except FileNotFoundError: |
| 390 | logger.log(f"Skipping vanished usr backup entry: {source_file}") |
| 391 | except OSError as exc: |
| 392 | logger.log(f"Skipping usr backup entry after read error: {source_file}: {exc}") |
| 393 | |
| 394 | destination.parent.mkdir(parents=True, exist_ok=True) |
| 395 | shutil.move(str(temporary_backup), str(destination)) |
| 396 | logger.log(f"Created usr backup at {destination}") |
| 397 | return destination |
| 398 | finally: |
| 399 | if temporary_backup.exists(): |
| 400 | temporary_backup.unlink(missing_ok=True) |
| 401 | |
| 402 | |
| 403 | def should_exclude_from_usr_backup( |
| 404 | relative_dir: Path, |
| 405 | logger: AttemptLogger, |
| 406 | ) -> bool: |
| 407 | parts = relative_dir.parts |
| 408 | if parts and parts[0] == ".time_travel": |
| 409 | logger.log( |
| 410 | f"Skipping Time Travel history during usr backup: {Path('usr') / relative_dir}" |
| 411 | ) |
| 412 | return True |
| 413 | if ( |
| 414 | len(parts) >= 6 |
| 415 | and parts[0] == "plugins" |
| 416 | and parts[1] == "_desktop" |
| 417 | and parts[2] == "profiles" |
| 418 | and parts[-2] == ".ssh" |
| 419 | and parts[-1] == "agent" |
| 420 | ): |
| 421 | logger.log(f"Skipping transient usr backup directory: {Path('usr') / relative_dir}") |
| 422 | return True |
| 423 | return False |
| 424 | |
| 425 | |
| 426 | def should_include_usr_backup_entry(source_file: Path, logger: AttemptLogger) -> bool: |
| 427 | try: |
| 428 | source_stat = source_file.lstat() |
| 429 | except FileNotFoundError: |
| 430 | logger.log(f"Skipping vanished usr backup entry: {source_file}") |
| 431 | return False |
| 432 | except OSError as exc: |
| 433 | logger.log(f"Skipping unreadable usr backup entry: {source_file}: {exc}") |
| 434 | return False |
| 435 | |
| 436 | if stat.S_ISLNK(source_stat.st_mode): |
| 437 | try: |
| 438 | target_stat = source_file.stat() |
| 439 | except FileNotFoundError: |
| 440 | logger.log(f"Skipping broken symlink during usr backup: {source_file}") |
| 441 | return False |
| 442 | except OSError as exc: |
| 443 | logger.log( |
| 444 | f"Skipping unreadable symlink target during usr backup: {source_file}: {exc}" |
| 445 | ) |
| 446 | return False |
| 447 | if not stat.S_ISREG(target_stat.st_mode): |
| 448 | logger.log( |
| 449 | f"Skipping non-regular symlink target during usr backup: {source_file}" |
| 450 | ) |
| 451 | return False |
| 452 | return True |
| 453 | |
| 454 | if not stat.S_ISREG(source_stat.st_mode): |
| 455 | logger.log(f"Skipping non-regular usr backup entry: {source_file}") |
| 456 | return False |
| 457 | |
| 458 | return True |
| 459 | |
| 460 | |
| 461 | def clean_transient_desktop_agent_state( |
| 462 | repo_dir: Path, |
| 463 | logger: AttemptLogger, |
| 464 | ) -> None: |
| 465 | profile_roots = 0 |
| 466 | removed = 0 |
| 467 | for relative_root in DESKTOP_PROFILE_STATE_RELATIVE_DIRS: |
| 468 | profile_root = repo_dir / relative_root |
| 469 | if not _is_cleanup_directory( |
| 470 | profile_root, |
| 471 | logger, |
| 472 | "Desktop profile state", |
| 473 | missing_ok=True, |
| 474 | ): |
| 475 | continue |
| 476 | profile_roots += 1 |
| 477 | try: |
| 478 | profiles = list(profile_root.iterdir()) |
| 479 | except OSError as exc: |
| 480 | logger.log(f"Desktop profile state could not be listed: {profile_root}: {exc}") |
| 481 | continue |
| 482 | for profile_dir in profiles: |
| 483 | if not _is_cleanup_directory(profile_dir, logger, "Desktop profile"): |
| 484 | continue |
| 485 | removed += _clean_directory_entries( |
| 486 | profile_dir / ".ssh" / "agent", |
| 487 | logger, |
| 488 | label="desktop SSH agent", |
| 489 | ) |
| 490 | removed += _clean_gnupg_agent_entries(profile_dir / ".gnupg", logger) |
| 491 | |
| 492 | if removed: |
| 493 | logger.log(f"Removed {removed} transient desktop agent entries.") |
| 494 | elif profile_roots: |
| 495 | logger.log("Transient desktop agent state already clean.") |
| 496 | else: |
| 497 | logger.log("No desktop profile runtime state found, skipping transient agent cleanup.") |
| 498 | |
| 499 | |
| 500 | def _clean_gnupg_agent_entries(gnupg_dir: Path, logger: AttemptLogger) -> int: |
| 501 | if not _is_cleanup_directory(gnupg_dir, logger, "desktop GnuPG state", missing_ok=True): |
| 502 | return 0 |
| 503 | try: |
| 504 | entries = list(gnupg_dir.iterdir()) |
| 505 | except OSError as exc: |
| 506 | logger.log(f"Desktop GnuPG state could not be listed: {gnupg_dir}: {exc}") |
| 507 | return 0 |
| 508 | |
| 509 | removed = 0 |
| 510 | for entry in entries: |
| 511 | if not entry.name.startswith("S.gpg-agent"): |
| 512 | continue |
| 513 | try: |
| 514 | entry_stat = entry.lstat() |
| 515 | except FileNotFoundError: |
| 516 | continue |
| 517 | except OSError as exc: |
| 518 | logger.log(f"Skipping transient desktop GnuPG agent entry after stat error: {entry}: {exc}") |
| 519 | continue |
| 520 | if stat.S_ISREG(entry_stat.st_mode): |
| 521 | continue |
| 522 | if _remove_cleanup_entry(entry, entry_stat, logger, label="desktop GnuPG agent"): |
| 523 | removed += 1 |
| 524 | return removed |
| 525 | |
| 526 | |
| 527 | def _clean_directory_entries(directory: Path, logger: AttemptLogger, *, label: str) -> int: |
| 528 | if not _is_cleanup_directory(directory, logger, label, missing_ok=True): |
| 529 | return 0 |
| 530 | try: |
| 531 | entries = list(directory.iterdir()) |
| 532 | except OSError as exc: |
| 533 | logger.log(f"Transient {label} directory could not be listed: {directory}: {exc}") |
| 534 | return 0 |
| 535 | |
| 536 | removed = 0 |
| 537 | for entry in entries: |
| 538 | try: |
| 539 | entry_stat = entry.lstat() |
| 540 | except FileNotFoundError: |
| 541 | continue |
| 542 | except OSError as exc: |
| 543 | logger.log(f"Skipping transient {label} entry after stat error: {entry}: {exc}") |
| 544 | continue |
| 545 | if _remove_cleanup_entry(entry, entry_stat, logger, label=label): |
| 546 | removed += 1 |
| 547 | return removed |
| 548 | |
| 549 | |
| 550 | def _is_cleanup_directory( |
| 551 | directory: Path, |
| 552 | logger: AttemptLogger, |
| 553 | label: str, |
| 554 | *, |
| 555 | missing_ok: bool = False, |
| 556 | ) -> bool: |
| 557 | try: |
| 558 | directory_stat = directory.lstat() |
| 559 | except FileNotFoundError: |
| 560 | if not missing_ok: |
| 561 | logger.log(f"{label} directory not found, skipping: {directory}") |
| 562 | return False |
| 563 | except OSError as exc: |
| 564 | logger.log(f"{label} directory could not be inspected: {directory}: {exc}") |
| 565 | return False |
| 566 | |
| 567 | if stat.S_ISLNK(directory_stat.st_mode) or not stat.S_ISDIR(directory_stat.st_mode): |
| 568 | logger.log(f"{label} path is not a directory, skipping: {directory}") |
| 569 | return False |
| 570 | return True |
| 571 | |
| 572 | |
| 573 | def _remove_cleanup_entry( |
| 574 | entry: Path, |
| 575 | entry_stat: os.stat_result, |
| 576 | logger: AttemptLogger, |
| 577 | *, |
| 578 | label: str, |
| 579 | ) -> bool: |
| 580 | try: |
| 581 | if stat.S_ISDIR(entry_stat.st_mode): |
| 582 | shutil.rmtree(entry) |
| 583 | else: |
| 584 | entry.unlink(missing_ok=True) |
| 585 | return True |
| 586 | except FileNotFoundError: |
| 587 | return False |
| 588 | except OSError as exc: |
| 589 | logger.log(f"Skipping transient {label} entry after error: {entry}: {exc}") |
| 590 | return False |
| 591 | |
| 592 | |
| 593 | def run_command( |
| 594 | command: list[str], |
| 595 | *, |
| 596 | cwd: Path | None, |
| 597 | logger: AttemptLogger, |
| 598 | error_message: str | None = None, |
| 599 | ) -> subprocess.CompletedProcess[str]: |
| 600 | logger.log(f"$ {' '.join(command)}") |
| 601 | completed = subprocess.run( |
| 602 | command, |
| 603 | cwd=cwd, |
| 604 | text=True, |
| 605 | capture_output=True, |
| 606 | env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, |
| 607 | ) |
| 608 | if completed.stdout: |
| 609 | logger.log_block("stdout", completed.stdout) |
| 610 | if completed.stderr: |
| 611 | logger.log_block("stderr", completed.stderr) |
| 612 | if completed.returncode != 0: |
| 613 | raise RuntimeError( |
| 614 | error_message |
| 615 | or f"Command failed with exit code {completed.returncode}: {' '.join(command)}" |
| 616 | ) |
| 617 | return completed |
| 618 | |
| 619 | |
| 620 | def clean_uv_cache(logger: AttemptLogger) -> None: |
| 621 | uv_path = shutil.which("uv") |
| 622 | if not uv_path: |
| 623 | logger.log("uv executable not found, skipping uv cache clean.") |
| 624 | return |
| 625 | |
| 626 | logger.log("Cleaning uv cache before continuing self-update startup.") |
| 627 | try: |
| 628 | run_command( |
| 629 | [uv_path, "cache", "clean"], |
| 630 | cwd=None, |
| 631 | logger=logger, |
| 632 | error_message="Failed to clean uv cache during self-update.", |
| 633 | ) |
| 634 | except Exception as exc: |
| 635 | logger.log(f"uv cache clean skipped after error: {exc}") |
| 636 | |
| 637 | |
| 638 | def refresh_codex_cli(logger: AttemptLogger) -> None: |
| 639 | codex_path = shutil.which("codex") |
| 640 | if not codex_path: |
| 641 | logger.log("Codex CLI not installed, skipping Codex refresh.") |
| 642 | return |
| 643 | |
| 644 | npm_path = shutil.which("npm") |
| 645 | if not npm_path: |
| 646 | logger.log("npm executable not found, skipping Codex refresh.") |
| 647 | return |
| 648 | |
| 649 | logger.log("Refreshing the installed Codex CLI after self-update.") |
| 650 | try: |
| 651 | run_command( |
| 652 | [npm_path, "install", "--global", "@openai/codex@latest"], |
| 653 | cwd=None, |
| 654 | logger=logger, |
| 655 | error_message="Failed to refresh the installed Codex CLI.", |
| 656 | ) |
| 657 | except Exception as exc: |
| 658 | logger.log(f"Codex CLI refresh skipped after error: {exc}") |
| 659 | |
| 660 | |
| 661 | def has_local_rollback_changes(repo_dir: Path) -> bool: |
| 662 | status = git_output(repo_dir, "status", "--porcelain=v1", "--untracked-files=all") |
| 663 | return bool(status.strip()) |
| 664 | |
| 665 | |
| 666 | def get_top_stash_ref(repo_dir: Path) -> str: |
| 667 | return git_optional_output(repo_dir, "stash", "list", "--format=%gd", "-n", "1") |
| 668 | |
| 669 | |
| 670 | def create_rollback_stash(repo_dir: Path, logger: AttemptLogger) -> str | None: |
| 671 | if not has_local_rollback_changes(repo_dir): |
| 672 | logger.log("No tracked or non-ignored untracked changes need rollback protection.") |
| 673 | return None |
| 674 | |
| 675 | previous_top = get_top_stash_ref(repo_dir) |
| 676 | message = f"a0-self-update rollback snapshot {now_iso()}" |
| 677 | run_command( |
| 678 | [ |
| 679 | "git", |
| 680 | "-C", |
| 681 | str(repo_dir), |
| 682 | "stash", |
| 683 | "push", |
| 684 | "--include-untracked", |
| 685 | "--message", |
| 686 | message, |
| 687 | ], |
| 688 | cwd=None, |
| 689 | logger=logger, |
| 690 | error_message="Failed to save local tracked/untracked changes before updating.", |
| 691 | ) |
| 692 | stash_ref = get_top_stash_ref(repo_dir) |
| 693 | if not stash_ref or stash_ref == previous_top: |
| 694 | raise RuntimeError("Failed to create the pre-update rollback stash.") |
| 695 | logger.log( |
| 696 | f"Saved local tracked/untracked changes into {stash_ref}. " |
| 697 | "Ignored files stay in place and are not stashed." |
| 698 | ) |
| 699 | return stash_ref |
| 700 | |
| 701 | |
| 702 | def drop_stash(repo_dir: Path, stash_ref: str, logger: AttemptLogger) -> None: |
| 703 | if not stash_ref: |
| 704 | return |
| 705 | run_command( |
| 706 | ["git", "-C", str(repo_dir), "stash", "drop", stash_ref], |
| 707 | cwd=None, |
| 708 | logger=logger, |
| 709 | error_message=f"Failed to drop temporary rollback stash {stash_ref}.", |
| 710 | ) |
| 711 | |
| 712 | |
| 713 | def apply_stash(repo_dir: Path, stash_ref: str, logger: AttemptLogger) -> None: |
| 714 | if not stash_ref: |
| 715 | return |
| 716 | run_command( |
| 717 | ["git", "-C", str(repo_dir), "stash", "apply", "--index", stash_ref], |
| 718 | cwd=None, |
| 719 | logger=logger, |
| 720 | error_message=( |
| 721 | f"Failed to restore local tracked/untracked changes from {stash_ref}. " |
| 722 | "The stash entry has been kept so it can be recovered manually." |
| 723 | ), |
| 724 | ) |
| 725 | try: |
| 726 | drop_stash(repo_dir, stash_ref, logger) |
| 727 | except Exception as exc: |
| 728 | logger.log( |
| 729 | f"Rollback stash {stash_ref} was restored but could not be dropped automatically: {exc}" |
| 730 | ) |
| 731 | |
| 732 | |
| 733 | def clean_repo_worktree( |
| 734 | repo_dir: Path, |
| 735 | logger: AttemptLogger, |
| 736 | *, |
| 737 | exclude_paths: list[Path] | None = None, |
| 738 | ) -> None: |
| 739 | command = ["git", "-C", str(repo_dir), "clean", "-ffd"] |
| 740 | for path in exclude_paths or []: |
| 741 | relative_path = get_repo_relative_path(repo_dir, path) |
| 742 | if relative_path: |
| 743 | command.extend(["-e", relative_path]) |
| 744 | run_command( |
| 745 | command, |
| 746 | cwd=None, |
| 747 | logger=logger, |
| 748 | error_message="Failed to remove leftover non-ignored files after checkout.", |
| 749 | ) |
| 750 | |
| 751 | |
| 752 | def fetch_release_refs(repo_dir: Path, branch: str, tag: str, logger: AttemptLogger) -> None: |
| 753 | remote_branch_ref = f"refs/remotes/a0-self-update/{branch}" |
| 754 | tag_commit_ref = get_tag_commit_ref(tag) |
| 755 | logger.log(f"Fetching branch {branch} and tag {tag} from {OFFICIAL_REPO_URL}") |
| 756 | run_command( |
| 757 | [ |
| 758 | "git", |
| 759 | "-C", |
| 760 | str(repo_dir), |
| 761 | "fetch", |
| 762 | "--force", |
| 763 | OFFICIAL_REPO_URL, |
| 764 | f"+refs/heads/{branch}:{remote_branch_ref}", |
| 765 | f"+refs/tags/{tag}:refs/tags/{tag}", |
| 766 | ], |
| 767 | cwd=None, |
| 768 | logger=logger, |
| 769 | error_message=f"Failed to fetch branch {branch} and tag {tag} from the official repository.", |
| 770 | ) |
| 771 | run_command( |
| 772 | [ |
| 773 | "git", |
| 774 | "-C", |
| 775 | str(repo_dir), |
| 776 | "merge-base", |
| 777 | "--is-ancestor", |
| 778 | tag_commit_ref, |
| 779 | remote_branch_ref, |
| 780 | ], |
| 781 | cwd=None, |
| 782 | logger=logger, |
| 783 | error_message=f"Requested tag {tag} is not reachable from official branch {branch}.", |
| 784 | ) |
| 785 | |
| 786 | |
| 787 | def fetch_branch_refs(repo_dir: Path, branch: str, logger: AttemptLogger) -> str: |
| 788 | remote_branch_ref = f"refs/remotes/a0-self-update/{branch}" |
| 789 | logger.log(f"Fetching branch {branch} and tags from {OFFICIAL_REPO_URL}") |
| 790 | run_command( |
| 791 | [ |
| 792 | "git", |
| 793 | "-C", |
| 794 | str(repo_dir), |
| 795 | "fetch", |
| 796 | "--force", |
| 797 | "--tags", |
| 798 | OFFICIAL_REPO_URL, |
| 799 | f"+refs/heads/{branch}:{remote_branch_ref}", |
| 800 | ], |
| 801 | cwd=None, |
| 802 | logger=logger, |
| 803 | error_message=f"Failed to fetch branch {branch} from the official repository.", |
| 804 | ) |
| 805 | return remote_branch_ref |
| 806 | |
| 807 | |
| 808 | def resolve_requested_target( |
| 809 | repo_dir: Path, |
| 810 | branch: str, |
| 811 | tag: str, |
| 812 | current_version: str, |
| 813 | logger: AttemptLogger, |
| 814 | ) -> dict[str, str]: |
| 815 | normalized_tag = tag.strip() |
| 816 | |
| 817 | if not is_latest_selector_tag(normalized_tag): |
| 818 | fetch_release_refs(repo_dir, branch, normalized_tag, logger) |
| 819 | tag_commit_ref = get_tag_commit_ref(normalized_tag) |
| 820 | return { |
| 821 | "requested_tag": normalized_tag, |
| 822 | "effective_tag": normalized_tag, |
| 823 | "target_ref": f"refs/tags/{normalized_tag}", |
| 824 | "expected_short_tag": normalized_tag, |
| 825 | "expected_commit": git_output(repo_dir, "rev-parse", tag_commit_ref), |
| 826 | "target_description": f"tag {normalized_tag}", |
| 827 | } |
| 828 | |
| 829 | remote_branch_ref = fetch_branch_refs(repo_dir, branch, logger) |
| 830 | if branch == "main": |
| 831 | effective_tag = get_latest_same_major_tag( |
| 832 | repo_dir, |
| 833 | branch_ref=remote_branch_ref, |
| 834 | current_version=current_version, |
| 835 | ) |
| 836 | tag_commit_ref = get_tag_commit_ref(effective_tag) |
| 837 | logger.log(f"Resolved latest on main to tag {effective_tag}") |
| 838 | return { |
| 839 | "requested_tag": LATEST_SELECTOR_TAG, |
| 840 | "effective_tag": effective_tag, |
| 841 | "target_ref": f"refs/tags/{effective_tag}", |
| 842 | "expected_short_tag": effective_tag, |
| 843 | "expected_commit": git_output(repo_dir, "rev-parse", tag_commit_ref), |
| 844 | "target_description": f"latest tag {effective_tag}", |
| 845 | } |
| 846 | |
| 847 | head_describe = git_output(repo_dir, "describe", "--tags", "--always", remote_branch_ref) |
| 848 | head_short_tag = normalize_describe_to_version(head_describe) |
| 849 | head_commit = git_output(repo_dir, "rev-parse", remote_branch_ref) |
| 850 | ensure_latest_target_matches_current_major( |
| 851 | branch=branch, |
| 852 | current_version=current_version, |
| 853 | target_version=head_short_tag, |
| 854 | ) |
| 855 | logger.log( |
| 856 | f"Resolved latest on branch {branch} to commit {head_commit[:7]} ({head_describe})" |
| 857 | ) |
| 858 | return { |
| 859 | "requested_tag": LATEST_SELECTOR_TAG, |
| 860 | "effective_tag": head_short_tag, |
| 861 | "target_ref": remote_branch_ref, |
| 862 | "expected_short_tag": head_short_tag, |
| 863 | "expected_commit": head_commit, |
| 864 | "target_description": f"latest branch state {head_describe}", |
| 865 | } |
| 866 | |
| 867 | |
| 868 | def checkout_target_release( |
| 869 | repo_dir: Path, |
| 870 | branch: str, |
| 871 | target_ref: str, |
| 872 | target_description: str, |
| 873 | logger: AttemptLogger, |
| 874 | *, |
| 875 | exclude_paths: list[Path] | None = None, |
| 876 | ) -> None: |
| 877 | logger.log(f"Checking out branch {branch} at {target_description}") |
| 878 | run_command( |
| 879 | [ |
| 880 | "git", |
| 881 | "-C", |
| 882 | str(repo_dir), |
| 883 | "checkout", |
| 884 | "-B", |
| 885 | branch, |
| 886 | target_ref, |
| 887 | ], |
| 888 | cwd=None, |
| 889 | logger=logger, |
| 890 | error_message=f"Failed to check out requested {target_description} on branch {branch}.", |
| 891 | ) |
| 892 | clean_repo_worktree(repo_dir, logger, exclude_paths=exclude_paths) |
| 893 | |
| 894 | |
| 895 | def restore_git_state( |
| 896 | repo_dir: Path, |
| 897 | *, |
| 898 | head: str, |
| 899 | branch: str, |
| 900 | logger: AttemptLogger, |
| 901 | exclude_paths: list[Path] | None = None, |
| 902 | ) -> None: |
| 903 | logger.log(f"Restoring repository state to commit {head}") |
| 904 | if branch: |
| 905 | run_command( |
| 906 | [ |
| 907 | "git", |
| 908 | "-C", |
| 909 | str(repo_dir), |
| 910 | "checkout", |
| 911 | "-B", |
| 912 | branch, |
| 913 | head, |
| 914 | ], |
| 915 | cwd=None, |
| 916 | logger=logger, |
| 917 | error_message=f"Failed to restore branch {branch} to commit {head}.", |
| 918 | ) |
| 919 | else: |
| 920 | run_command( |
| 921 | [ |
| 922 | "git", |
| 923 | "-C", |
| 924 | str(repo_dir), |
| 925 | "checkout", |
| 926 | "--detach", |
| 927 | head, |
| 928 | ], |
| 929 | cwd=None, |
| 930 | logger=logger, |
| 931 | error_message=f"Failed to restore detached HEAD at commit {head}.", |
| 932 | ) |
| 933 | clean_repo_worktree(repo_dir, logger, exclude_paths=exclude_paths) |
| 934 | |
| 935 | |
| 936 | def launch_ui_process(repo_dir: Path, logger: AttemptLogger) -> subprocess.Popen[bytes]: |
| 937 | run_office_cleanup_hook(repo_dir, logger) |
| 938 | |
| 939 | prepare_script = repo_dir / "prepare.py" |
| 940 | if prepare_script.exists(): |
| 941 | logger.log("Running prepare.py before UI start") |
| 942 | run_command([sys.executable, str(prepare_script), "--dockerized=true"], cwd=repo_dir, logger=logger) |
| 943 | else: |
| 944 | logger.log("prepare.py not found, skipping prepare step") |
| 945 | |
| 946 | logger.log("Starting Agent Zero UI") |
| 947 | return subprocess.Popen( |
| 948 | [ |
| 949 | sys.executable, |
| 950 | str(repo_dir / "run_ui.py"), |
| 951 | "--dockerized=true", |
| 952 | "--port=80", |
| 953 | "--host=0.0.0.0", |
| 954 | ], |
| 955 | cwd=repo_dir, |
| 956 | ) |
| 957 | |
| 958 | |
| 959 | def run_office_cleanup_hook(repo_dir: Path, logger: AttemptLogger) -> None: |
| 960 | hook_path = repo_dir / "plugins" / "_office" / "hooks.py" |
| 961 | if not hook_path.exists(): |
| 962 | return |
| 963 | try: |
| 964 | if str(repo_dir) not in sys.path: |
| 965 | sys.path.insert(0, str(repo_dir)) |
| 966 | spec = importlib.util.spec_from_file_location("a0_office_hooks", hook_path) |
| 967 | if spec is None or spec.loader is None: |
| 968 | logger.log("Office cleanup hook could not be loaded.") |
| 969 | return |
| 970 | module = importlib.util.module_from_spec(spec) |
| 971 | spec.loader.exec_module(module) |
| 972 | cleanup = getattr(module, "cleanup_stale_runtime_state", None) |
| 973 | if not callable(cleanup): |
| 974 | return |
| 975 | result = cleanup() |
| 976 | if isinstance(result, dict) and result.get("errors"): |
| 977 | logger.log(f"Office cleanup hook reported errors: {result.get('errors')}") |
| 978 | else: |
| 979 | logger.log("Office cleanup hook completed.") |
| 980 | except Exception as exc: |
| 981 | logger.log(f"Office cleanup hook skipped after error: {exc}") |
| 982 | |
| 983 | |
| 984 | def wait_for_health( |
| 985 | process: subprocess.Popen[bytes], |
| 986 | *, |
| 987 | health_url: str, |
| 988 | timeout_seconds: int, |
| 989 | poll_interval_seconds: float, |
| 990 | expected_version: str | None = None, |
| 991 | expected_commit: str | None = None, |
| 992 | logger: AttemptLogger, |
| 993 | ) -> tuple[bool, dict[str, Any] | str]: |
| 994 | deadline = time.monotonic() + timeout_seconds |
| 995 | last_error = "Health check did not return a successful response." |
| 996 | |
| 997 | while time.monotonic() < deadline: |
| 998 | if process.poll() is not None: |
| 999 | return ( |
| 1000 | False, |
| 1001 | f"UI process exited with code {process.returncode} before passing the health check.", |
| 1002 | ) |
| 1003 | try: |
| 1004 | request = urllib.request.Request( |
| 1005 | health_url, |
| 1006 | headers={"Cache-Control": "no-cache"}, |
| 1007 | method="GET", |
| 1008 | ) |
| 1009 | with urllib.request.urlopen(request, timeout=5) as response: |
| 1010 | body = response.read().decode("utf-8") |
| 1011 | payload = json.loads(body) if body else {} |
| 1012 | git_info = payload.get("gitinfo") or {} |
| 1013 | current_version = (git_info.get("short_tag") or "").strip() |
| 1014 | current_commit = (git_info.get("commit_hash") or "").strip() |
| 1015 | if expected_commit and current_commit and current_commit != expected_commit: |
| 1016 | last_error = ( |
| 1017 | f"Health check responded, but commit {current_commit} does not match " |
| 1018 | f"expected {expected_commit}." |
| 1019 | ) |
| 1020 | elif expected_version and current_version and current_version != expected_version: |
| 1021 | last_error = ( |
| 1022 | f"Health check responded, but version {current_version} does not match " |
| 1023 | f"expected {expected_version}." |
| 1024 | ) |
| 1025 | elif response.status == 200: |
| 1026 | logger.log(f"Health check passed at {health_url}") |
| 1027 | return True, payload |
| 1028 | except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc: |
| 1029 | last_error = str(exc) |
| 1030 | |
| 1031 | time.sleep(poll_interval_seconds) |
| 1032 | |
| 1033 | return False, last_error |
| 1034 | |
| 1035 | |
| 1036 | def terminate_process(process: subprocess.Popen[bytes], timeout_seconds: int = 20) -> None: |
| 1037 | if process.poll() is not None: |
| 1038 | return |
| 1039 | process.terminate() |
| 1040 | try: |
| 1041 | process.wait(timeout=timeout_seconds) |
| 1042 | except subprocess.TimeoutExpired: |
| 1043 | process.kill() |
| 1044 | process.wait(timeout=5) |
| 1045 | |
| 1046 | |
| 1047 | def wait_for_process(process: subprocess.Popen[bytes]) -> int: |
| 1048 | def forward_signal(signum, _frame) -> None: |
| 1049 | if process.poll() is None: |
| 1050 | process.send_signal(signum) |
| 1051 | |
| 1052 | for sig in (signal.SIGTERM, signal.SIGINT): |
| 1053 | try: |
| 1054 | signal.signal(sig, forward_signal) |
| 1055 | except ValueError: |
| 1056 | pass |
| 1057 | |
| 1058 | return process.wait() |
| 1059 | |
| 1060 | |
| 1061 | def record_result( |
| 1062 | *, |
| 1063 | status: str, |
| 1064 | message: str, |
| 1065 | request_data: dict[str, Any], |
| 1066 | source_info: dict[str, str], |
| 1067 | current_version: str, |
| 1068 | started_at: str, |
| 1069 | backup_zip_path: str = "", |
| 1070 | rollback_applied: bool = False, |
| 1071 | error: str = "", |
| 1072 | ) -> None: |
| 1073 | payload: dict[str, Any] = { |
| 1074 | "status": status, |
| 1075 | "message": message, |
| 1076 | "branch": str(request_data.get("branch", "")), |
| 1077 | "tag": str(request_data.get("tag", "")), |
| 1078 | "source_version": source_info["short_tag"], |
| 1079 | "source_commit": source_info["commit"], |
| 1080 | "current_version": current_version, |
| 1081 | "requested_at": str(request_data.get("requested_at", "")), |
| 1082 | "started_at": started_at, |
| 1083 | "finished_at": now_iso(), |
| 1084 | "log_file_path": str(LOG_FILE), |
| 1085 | "update_file_path": str(TRIGGER_FILE), |
| 1086 | "rollback_applied": rollback_applied, |
| 1087 | } |
| 1088 | if backup_zip_path: |
| 1089 | payload["backup_zip_path"] = backup_zip_path |
| 1090 | if error: |
| 1091 | payload["error"] = error |
| 1092 | write_status(payload) |
| 1093 | |
| 1094 | |
| 1095 | def execute_pending_update( |
| 1096 | request_data: dict[str, Any], |
| 1097 | *, |
| 1098 | logger: AttemptLogger, |
| 1099 | ) -> subprocess.Popen[bytes]: |
| 1100 | source_info = get_repo_version_info(REPO_DIR) |
| 1101 | started_at = now_iso() |
| 1102 | backup_zip_path = "" |
| 1103 | stash_ref: str | None = None |
| 1104 | repository_changed = False |
| 1105 | branch = str(request_data.get("branch", "")).strip() |
| 1106 | tag = str(request_data.get("tag", "")).strip() |
| 1107 | backup_exclusions: list[Path] = [] |
| 1108 | resolved_target: dict[str, str] | None = None |
| 1109 | |
| 1110 | try: |
| 1111 | if not branch: |
| 1112 | raise ValueError("Update file is missing the branch field.") |
| 1113 | if not tag: |
| 1114 | raise ValueError("Update file is missing the tag field.") |
| 1115 | |
| 1116 | stash_ref = create_rollback_stash(REPO_DIR, logger) |
| 1117 | |
| 1118 | if bool(request_data.get("backup_usr", True)): |
| 1119 | backup_destination = create_usr_backup( |
| 1120 | repo_dir=REPO_DIR, |
| 1121 | backup_path=str(request_data.get("backup_path", "/root/update-backups")), |
| 1122 | backup_name=str(request_data.get("backup_name", "agent-zero-usr-backup.zip")), |
| 1123 | conflict_policy=str(request_data.get("backup_conflict_policy", "rename")), |
| 1124 | logger=logger, |
| 1125 | ) |
| 1126 | backup_zip_path = str(backup_destination) |
| 1127 | backup_exclusions.append(backup_destination) |
| 1128 | |
| 1129 | resolved_target = resolve_requested_target( |
| 1130 | REPO_DIR, |
| 1131 | branch, |
| 1132 | tag, |
| 1133 | source_info["short_tag"], |
| 1134 | logger, |
| 1135 | ) |
| 1136 | |
| 1137 | repository_changed = True |
| 1138 | logger.log( |
| 1139 | "Applying the requested release with native Git checkout. " |
| 1140 | "Ignored files remain untouched; tracked files and non-ignored leftovers are replaced." |
| 1141 | ) |
| 1142 | checkout_target_release( |
| 1143 | REPO_DIR, |
| 1144 | branch, |
| 1145 | resolved_target["target_ref"], |
| 1146 | resolved_target["target_description"], |
| 1147 | logger, |
| 1148 | exclude_paths=backup_exclusions, |
| 1149 | ) |
| 1150 | |
| 1151 | current_info = get_repo_version_info(REPO_DIR) |
| 1152 | if resolved_target.get("expected_commit") and current_info["commit"] != resolved_target["expected_commit"]: |
| 1153 | raise RuntimeError( |
| 1154 | "Git checkout completed but the repository commit does not match the requested target. " |
| 1155 | f"Expected {resolved_target['expected_commit']}, got {current_info['commit']}." |
| 1156 | ) |
| 1157 | if resolved_target.get("expected_short_tag") and current_info["short_tag"] != resolved_target["expected_short_tag"]: |
| 1158 | raise RuntimeError( |
| 1159 | "Git checkout completed but the repository version does not match the requested tag. " |
| 1160 | f"Expected {resolved_target['expected_short_tag']}, got {current_info['short_tag']}." |
| 1161 | ) |
| 1162 | |
| 1163 | updated_process = launch_ui_process(REPO_DIR, logger) |
| 1164 | healthy, details = wait_for_health( |
| 1165 | updated_process, |
| 1166 | health_url=DEFAULT_HEALTH_URL, |
| 1167 | timeout_seconds=DEFAULT_HEALTH_TIMEOUT_SECONDS, |
| 1168 | poll_interval_seconds=DEFAULT_HEALTH_POLL_INTERVAL_SECONDS, |
| 1169 | expected_version=resolved_target.get("expected_short_tag"), |
| 1170 | expected_commit=resolved_target.get("expected_commit"), |
| 1171 | logger=logger, |
| 1172 | ) |
| 1173 | if healthy: |
| 1174 | refresh_codex_cli(logger) |
| 1175 | record_result( |
| 1176 | status="success", |
| 1177 | message=f"Updated Agent Zero to branch {branch}, {resolved_target['target_description']}.", |
| 1178 | request_data=request_data, |
| 1179 | source_info=source_info, |
| 1180 | current_version=current_info["short_tag"], |
| 1181 | started_at=started_at, |
| 1182 | backup_zip_path=backup_zip_path, |
| 1183 | rollback_applied=False, |
| 1184 | ) |
| 1185 | if stash_ref: |
| 1186 | logger.log( |
| 1187 | f"Update succeeded, dropping temporary rollback stash {stash_ref}. " |
| 1188 | "Tracked and non-ignored local changes were not reapplied." |
| 1189 | ) |
| 1190 | try: |
| 1191 | drop_stash(REPO_DIR, stash_ref, logger) |
| 1192 | except Exception as exc: |
| 1193 | logger.log( |
| 1194 | f"Temporary rollback stash {stash_ref} could not be dropped automatically: {exc}" |
| 1195 | ) |
| 1196 | return updated_process |
| 1197 | |
| 1198 | logger.log(f"Updated UI failed health check, rolling back: {details}") |
| 1199 | terminate_process(updated_process) |
| 1200 | restore_git_state( |
| 1201 | REPO_DIR, |
| 1202 | head=source_info["commit"], |
| 1203 | branch=source_info.get("branch", ""), |
| 1204 | logger=logger, |
| 1205 | exclude_paths=backup_exclusions, |
| 1206 | ) |
| 1207 | apply_stash(REPO_DIR, stash_ref or "", logger) |
| 1208 | stash_ref = None |
| 1209 | |
| 1210 | rollback_process = launch_ui_process(REPO_DIR, logger) |
| 1211 | rollback_healthy, rollback_details = wait_for_health( |
| 1212 | rollback_process, |
| 1213 | health_url=DEFAULT_HEALTH_URL, |
| 1214 | timeout_seconds=DEFAULT_HEALTH_TIMEOUT_SECONDS, |
| 1215 | poll_interval_seconds=DEFAULT_HEALTH_POLL_INTERVAL_SECONDS, |
| 1216 | expected_version=source_info["short_tag"], |
| 1217 | logger=logger, |
| 1218 | ) |
| 1219 | |
| 1220 | if rollback_healthy: |
| 1221 | record_result( |
| 1222 | status="rolled_back", |
| 1223 | message=( |
| 1224 | "Updated version failed its health check and the previous version was restored. " |
| 1225 | f"Reason: {details}" |
| 1226 | ), |
| 1227 | request_data=request_data, |
| 1228 | source_info=source_info, |
| 1229 | current_version=source_info["short_tag"], |
| 1230 | started_at=started_at, |
| 1231 | backup_zip_path=backup_zip_path, |
| 1232 | rollback_applied=True, |
| 1233 | error=str(details), |
| 1234 | ) |
| 1235 | return rollback_process |
| 1236 | |
| 1237 | terminate_process(rollback_process) |
| 1238 | record_result( |
| 1239 | status="rollback_failed", |
| 1240 | message=( |
| 1241 | "Updated version failed its health check and rollback also failed to become healthy." |
| 1242 | ), |
| 1243 | request_data=request_data, |
| 1244 | source_info=source_info, |
| 1245 | current_version=source_info["short_tag"], |
| 1246 | started_at=started_at, |
| 1247 | backup_zip_path=backup_zip_path, |
| 1248 | rollback_applied=True, |
| 1249 | error=f"Update error: {details}. Rollback error: {rollback_details}", |
| 1250 | ) |
| 1251 | raise RuntimeError(str(rollback_details)) |
| 1252 | except Exception as exc: |
| 1253 | restore_error = "" |
| 1254 | if repository_changed or stash_ref: |
| 1255 | logger.log(f"Restoring pre-update repository state after error: {exc}") |
| 1256 | try: |
| 1257 | restore_git_state( |
| 1258 | REPO_DIR, |
| 1259 | head=source_info["commit"], |
| 1260 | branch=source_info.get("branch", ""), |
| 1261 | logger=logger, |
| 1262 | exclude_paths=backup_exclusions, |
| 1263 | ) |
| 1264 | if stash_ref: |
| 1265 | apply_stash(REPO_DIR, stash_ref, logger) |
| 1266 | stash_ref = None |
| 1267 | except Exception as restore_exc: |
| 1268 | restore_error = str(restore_exc) |
| 1269 | logger.log(f"Automatic restore failed: {restore_exc}") |
| 1270 | |
| 1271 | failure_message = str(exc) |
| 1272 | if restore_error: |
| 1273 | failure_message = f"{failure_message} | Restore error: {restore_error}" |
| 1274 | |
| 1275 | failure_status = "failed" |
| 1276 | if repository_changed: |
| 1277 | failure_status = "rollback_failed" if restore_error else "rolled_back" |
| 1278 | |
| 1279 | record_result( |
| 1280 | status=failure_status, |
| 1281 | message=failure_message, |
| 1282 | request_data=request_data, |
| 1283 | source_info=source_info, |
| 1284 | current_version=source_info["short_tag"], |
| 1285 | started_at=started_at, |
| 1286 | backup_zip_path=backup_zip_path, |
| 1287 | rollback_applied=repository_changed, |
| 1288 | error=failure_message, |
| 1289 | ) |
| 1290 | logger.log(f"Update flow failed: {failure_message}") |
| 1291 | return launch_ui_process(REPO_DIR, logger) |
| 1292 | |
| 1293 | |
| 1294 | def load_request_file() -> tuple[dict[str, Any] | None, str]: |
| 1295 | if not TRIGGER_FILE.exists(): |
| 1296 | return None, "" |
| 1297 | raw_text = TRIGGER_FILE.read_text(encoding="utf-8") |
| 1298 | try: |
| 1299 | loaded = yaml.safe_load(raw_text) |
| 1300 | return (loaded if isinstance(loaded, dict) else None), raw_text |
| 1301 | finally: |
| 1302 | TRIGGER_FILE.unlink(missing_ok=True) |
| 1303 | |
| 1304 | |
| 1305 | def queue_update_request( |
| 1306 | *, |
| 1307 | branch: str = "main", |
| 1308 | tag: str = LATEST_SELECTOR_TAG, |
| 1309 | backup_usr: bool = True, |
| 1310 | backup_path: str = DEFAULT_BACKUP_DIR, |
| 1311 | backup_name: str = "", |
| 1312 | backup_conflict_policy: str = DEFAULT_BACKUP_CONFLICT_POLICY, |
| 1313 | ) -> dict[str, Any]: |
| 1314 | source_info = get_repo_version_info(REPO_DIR) |
| 1315 | normalized_branch = (branch or "").strip().lower() or "main" |
| 1316 | normalized_tag = normalize_requested_tag(tag) |
| 1317 | normalized_policy = normalize_backup_conflict_policy(backup_conflict_policy) |
| 1318 | normalized_backup_path = (backup_path or "").strip() or DEFAULT_BACKUP_DIR |
| 1319 | normalized_backup_name = sanitize_filename( |
| 1320 | backup_name, |
| 1321 | build_default_backup_name(), |
| 1322 | ) |
| 1323 | |
| 1324 | payload = { |
| 1325 | "branch": normalized_branch, |
| 1326 | "tag": normalized_tag, |
| 1327 | "source_version": source_info["short_tag"], |
| 1328 | "source_describe": source_info["describe"], |
| 1329 | "source_commit": source_info["commit"], |
| 1330 | "requested_at": now_iso(), |
| 1331 | "backup_usr": bool(backup_usr), |
| 1332 | "backup_path": normalized_backup_path, |
| 1333 | "backup_name": normalized_backup_name, |
| 1334 | "backup_conflict_policy": normalized_policy, |
| 1335 | } |
| 1336 | write_yaml(TRIGGER_FILE, payload) |
| 1337 | return payload |
| 1338 | |
| 1339 | |
| 1340 | def installed_target_matches_request( |
| 1341 | current_info: dict[str, str], |
| 1342 | *, |
| 1343 | requested_branch: str, |
| 1344 | requested_tag: str, |
| 1345 | ) -> bool: |
| 1346 | normalized_tag = requested_tag.strip() |
| 1347 | if not normalized_tag or is_latest_selector_tag(normalized_tag): |
| 1348 | return False |
| 1349 | |
| 1350 | current_branch = current_info.get("branch", "").strip() |
| 1351 | if requested_branch.strip() and current_branch != requested_branch.strip(): |
| 1352 | return False |
| 1353 | |
| 1354 | return current_info.get("describe", "").strip() == normalized_tag |
| 1355 | |
| 1356 | |
| 1357 | def trigger_update_command(args: list[str]) -> int: |
| 1358 | parser = argparse.ArgumentParser( |
| 1359 | prog="trigger_self_update.sh", |
| 1360 | description="Queue an Agent Zero self-update for the next startup attempt.", |
| 1361 | ) |
| 1362 | parser.add_argument( |
| 1363 | "branch", |
| 1364 | nargs="?", |
| 1365 | default="main", |
| 1366 | help="Target official branch. Default: main", |
| 1367 | ) |
| 1368 | parser.add_argument( |
| 1369 | "tag", |
| 1370 | nargs="?", |
| 1371 | default=LATEST_SELECTOR_TAG, |
| 1372 | help='Target release tag such as v1.10 or "latest". Default: latest', |
| 1373 | ) |
| 1374 | parser.add_argument( |
| 1375 | "--backup-dir", |
| 1376 | default=DEFAULT_BACKUP_DIR, |
| 1377 | help=f"Directory for the usr backup zip. Default: {DEFAULT_BACKUP_DIR}", |
| 1378 | ) |
| 1379 | parser.add_argument( |
| 1380 | "--backup-name", |
| 1381 | default="", |
| 1382 | help="Backup zip filename. Default: autogenerated usr-YYYYMMDD-HHMMSS.zip", |
| 1383 | ) |
| 1384 | parser.add_argument( |
| 1385 | "--backup-conflict-policy", |
| 1386 | default=DEFAULT_BACKUP_CONFLICT_POLICY, |
| 1387 | choices=sorted(BACKUP_CONFLICT_POLICIES), |
| 1388 | help="How to handle an existing backup zip. Default: rename", |
| 1389 | ) |
| 1390 | parser.add_argument( |
| 1391 | "--no-backup", |
| 1392 | action="store_true", |
| 1393 | help="Skip creating a usr backup before the update.", |
| 1394 | ) |
| 1395 | parsed = parser.parse_args(args) |
| 1396 | |
| 1397 | try: |
| 1398 | payload = queue_update_request( |
| 1399 | branch=parsed.branch, |
| 1400 | tag=parsed.tag, |
| 1401 | backup_usr=not parsed.no_backup, |
| 1402 | backup_path=parsed.backup_dir, |
| 1403 | backup_name=parsed.backup_name, |
| 1404 | backup_conflict_policy=parsed.backup_conflict_policy, |
| 1405 | ) |
| 1406 | except Exception as exc: |
| 1407 | print(f"Failed to queue self-update: {exc}", file=sys.stderr) |
| 1408 | return 1 |
| 1409 | |
| 1410 | print("Queued Agent Zero self-update for the next startup attempt.") |
| 1411 | print(f"Branch: {payload['branch']}") |
| 1412 | print(f"Version: {payload['tag']}") |
| 1413 | if payload["backup_usr"]: |
| 1414 | print(f"Backup dir: {payload['backup_path']}") |
| 1415 | print(f"Backup name: {payload['backup_name']}") |
| 1416 | print(f"Backup conflict policy: {payload['backup_conflict_policy']}") |
| 1417 | else: |
| 1418 | print("Backup: disabled") |
| 1419 | print(f"Trigger file: {TRIGGER_FILE}") |
| 1420 | print(f"Log file: {LOG_FILE}") |
| 1421 | print("Restart the container or Agent Zero process to apply it.") |
| 1422 | return 0 |
| 1423 | |
| 1424 | |
| 1425 | def docker_run_ui() -> int: |
| 1426 | request_data, raw_text = load_request_file() |
| 1427 | logger = AttemptLogger(LOG_FILE) |
| 1428 | quiet_logger = NullLogger() |
| 1429 | |
| 1430 | if request_data: |
| 1431 | logger.reset() |
| 1432 | logger.log(f"Consumed update file at {TRIGGER_FILE}") |
| 1433 | logger.log_block("Trigger file content", raw_text) |
| 1434 | clean_uv_cache(logger) |
| 1435 | try: |
| 1436 | clean_transient_desktop_agent_state(REPO_DIR, logger) |
| 1437 | except Exception as exc: |
| 1438 | logger.log(f"Transient desktop agent cleanup skipped after error: {exc}") |
| 1439 | |
| 1440 | try: |
| 1441 | current = get_repo_version_info(REPO_DIR) |
| 1442 | requested_branch = str(request_data.get("branch", "")).strip() |
| 1443 | requested_tag = str(request_data.get("tag", "")).strip() |
| 1444 | if installed_target_matches_request( |
| 1445 | current, |
| 1446 | requested_branch=requested_branch, |
| 1447 | requested_tag=requested_tag, |
| 1448 | ): |
| 1449 | logger.log( |
| 1450 | "Requested tag already matches the installed version, skipping file replacement." |
| 1451 | ) |
| 1452 | refresh_codex_cli(logger) |
| 1453 | record_result( |
| 1454 | status="skipped", |
| 1455 | message="Requested tag already matches the installed version.", |
| 1456 | request_data=request_data, |
| 1457 | source_info=current, |
| 1458 | current_version=current["short_tag"], |
| 1459 | started_at=now_iso(), |
| 1460 | rollback_applied=False, |
| 1461 | ) |
| 1462 | process = launch_ui_process(REPO_DIR, logger) |
| 1463 | else: |
| 1464 | process = execute_pending_update(request_data, logger=logger) |
| 1465 | except Exception as exc: |
| 1466 | logger.log(f"Self-update bootstrap failed unexpectedly: {exc}") |
| 1467 | process = launch_ui_process(REPO_DIR, logger) |
| 1468 | elif raw_text: |
| 1469 | logger.reset() |
| 1470 | logger.log(f"Consumed invalid update file at {TRIGGER_FILE}") |
| 1471 | logger.log_block("Trigger file content", raw_text) |
| 1472 | source_info = get_repo_version_info(REPO_DIR) |
| 1473 | record_result( |
| 1474 | status="failed", |
| 1475 | message="Update file was not valid YAML.", |
| 1476 | request_data={}, |
| 1477 | source_info=source_info, |
| 1478 | current_version=source_info["short_tag"], |
| 1479 | started_at=now_iso(), |
| 1480 | rollback_applied=False, |
| 1481 | error="Update file was not valid YAML.", |
| 1482 | ) |
| 1483 | process = launch_ui_process(REPO_DIR, logger) |
| 1484 | else: |
| 1485 | process = launch_ui_process(REPO_DIR, quiet_logger) |
| 1486 | |
| 1487 | return wait_for_process(process) |
| 1488 | |
| 1489 | |
| 1490 | def main(argv: list[str] | None = None) -> int: |
| 1491 | args = list(argv if argv is not None else sys.argv[1:]) |
| 1492 | if not args or args[0] == "docker-run-ui": |
| 1493 | return docker_run_ui() |
| 1494 | if args[0] == "trigger-update": |
| 1495 | return trigger_update_command(args[1:]) |
| 1496 | if args[0] == "refresh-codex": |
| 1497 | refresh_codex_cli(AttemptLogger(LOG_FILE)) |
| 1498 | return 0 |
| 1499 | if args[0] in {"-h", "--help"}: |
| 1500 | print("Usage: self_update_manager.py [docker-run-ui | trigger-update ... | refresh-codex]") |
| 1501 | return 0 |
| 1502 | print(f"Unknown command: {args[0]}", file=sys.stderr) |
| 1503 | return 1 |
| 1504 | |
| 1505 | |
| 1506 | if __name__ == "__main__": |
| 1507 | raise SystemExit(main()) |