main
py 1,507 lines 49.5 KB
Raw
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())