Update system prototype

frdel committed Mar 24, 2026 at 13:49 UTC 94edfeaf8d0f75bab895de382be54c113b9037e6
8 files changed +1640 -8
api/self_update_get.py new
+27
@@ -0,0 +1,27 @@
1 +from helpers.api import ApiHandler, Request, Response
2 +
3 +from helpers import runtime
4 +from helpers import self_update
5 +
6 +
7 +class SelfUpdateGet(ApiHandler):
8 + @classmethod
9 + def get_methods(cls) -> list[str]:
10 + return ["GET", "POST"]
11 +
12 + async def process(self, input: dict, request: Request) -> dict | Response:
13 + try:
14 + info = self_update.get_update_info()
15 + return {
16 + "success": True,
17 + "supported": runtime.is_dockerized(),
18 + **info,
19 + }
20 + except Exception as e:
21 + return {
22 + "success": False,
23 + "supported": runtime.is_dockerized(),
24 + "error": str(e),
25 + "pending": self_update.load_pending_update(),
26 + "last_status": self_update.load_last_status(),
27 + }
api/self_update_schedule.py new
+35
@@ -0,0 +1,35 @@
1 +from helpers.api import ApiHandler, Request, Response
2 +
3 +from helpers import runtime
4 +from helpers import self_update
5 +
6 +
7 +class SelfUpdateSchedule(ApiHandler):
8 + async def process(self, input: dict, request: Request) -> dict | Response:
9 + if not runtime.is_dockerized():
10 + return {
11 + "success": False,
12 + "error": "Self-update is only available in dockerized installations.",
13 + }
14 +
15 + try:
16 + pending = self_update.schedule_update(
17 + branch=str(input.get("branch", "")),
18 + tag=str(input.get("tag", "")),
19 + backup_usr=bool(input.get("backup_usr", True)),
20 + backup_path=str(input.get("backup_path", "")),
21 + backup_name=str(input.get("backup_name", "")),
22 + backup_conflict_policy=str(input.get("backup_conflict_policy", "rename")),
23 + )
24 + return {
25 + "success": True,
26 + "pending": pending,
27 + "message": (
28 + "Self-update was scheduled. Restart Agent Zero to apply the requested branch/tag."
29 + ),
30 + }
31 + except Exception as e:
32 + return {
33 + "success": False,
34 + "error": str(e),
35 + }
docker/run/fs/exe/run_A0.sh
+2 -8
@@ -3,11 +3,5 @@
3 . "/ins/setup_venv.sh" "$@"
4 . "/ins/copy_A0.sh" "$@"
5
6 -python /a0/prepare.py --dockerized=true
7 -# python /a0/preload.py --dockerized=true # no need to run preload if it's done during container build
8 -
9 -echo "Starting A0..."
10 -exec python /a0/run_ui.py \
11 - --dockerized=true \
12 - --port=80 \
13 - --host="0.0.0.0"
6 +echo "Starting A0 bootstrap manager..."
7 +exec python /exe/self_update_manager.py docker-run-ui
docker/run/fs/exe/self_update_manager.py new
+723
@@ -0,0 +1,723 @@
1 +#!/usr/bin/env python3
2 +from __future__ import annotations
3 +
4 +import json
5 +import os
6 +import re
7 +import shutil
8 +import signal
9 +import subprocess
10 +import sys
11 +import tempfile
12 +import time
13 +import urllib.error
14 +import urllib.request
15 +import zipfile
16 +from datetime import UTC, datetime
17 +from pathlib import Path
18 +from typing import Any
19 +
20 +import yaml
21 +
22 +
23 +OFFICIAL_REPO_URL = os.environ.get(
24 + "A0_SELF_UPDATE_REMOTE_URL",
25 + "https://github.com/agent0ai/agent-zero.git",
26 +)
27 +REPO_DIR = Path("/a0")
28 +TRIGGER_FILE = Path("/exe/a0-self-update.yaml")
29 +STATUS_FILE = Path("/exe/a0-self-update-status.yaml")
30 +LOG_FILE = Path("/exe/a0-self-update.log")
31 +DEFAULT_HEALTH_URL = os.environ.get(
32 + "A0_SELF_UPDATE_HEALTH_URL",
33 + "http://127.0.0.1:80/api/health",
34 +)
35 +DEFAULT_HEALTH_TIMEOUT_SECONDS = int(
36 + os.environ.get("A0_SELF_UPDATE_HEALTH_TIMEOUT_SECONDS", "120")
37 +)
38 +DEFAULT_HEALTH_POLL_INTERVAL_SECONDS = float(
39 + os.environ.get("A0_SELF_UPDATE_HEALTH_POLL_INTERVAL_SECONDS", "2")
40 +)
41 +
42 +
43 +def now_iso() -> str:
44 + return datetime.now(UTC).isoformat().replace("+00:00", "Z")
45 +
46 +
47 +class AttemptLogger:
48 + def __init__(self, path: Path):
49 + self.path = path
50 +
51 + def reset(self) -> None:
52 + self.path.parent.mkdir(parents=True, exist_ok=True)
53 + self.path.write_text("", encoding="utf-8")
54 +
55 + def log(self, message: str = "") -> None:
56 + line = f"[{now_iso()}] {message}".rstrip()
57 + print(f"[a0-self-update] {message}", flush=True)
58 + with self.path.open("a", encoding="utf-8") as handle:
59 + handle.write(line + "\n")
60 +
61 + def log_block(self, title: str, content: str) -> None:
62 + cleaned = content.rstrip()
63 + self.log(f"{title}:")
64 + if not cleaned:
65 + self.log("(empty)")
66 + return
67 + with self.path.open("a", encoding="utf-8") as handle:
68 + for line in cleaned.splitlines():
69 + handle.write(f" {line}\n")
70 +
71 +
72 +class NullLogger:
73 + def reset(self) -> None:
74 + return
75 +
76 + def log(self, message: str = "") -> None:
77 + return
78 +
79 + def log_block(self, title: str, content: str) -> None:
80 + return
81 +
82 +
83 +def load_yaml(path: Path) -> dict[str, Any] | None:
84 + if not path.exists():
85 + return None
86 + loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
87 + return loaded if isinstance(loaded, dict) else None
88 +
89 +
90 +def write_yaml(path: Path, payload: dict[str, Any]) -> None:
91 + path.parent.mkdir(parents=True, exist_ok=True)
92 + path.write_text(
93 + yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
94 + encoding="utf-8",
95 + )
96 +
97 +
98 +def write_status(payload: dict[str, Any]) -> None:
99 + write_yaml(STATUS_FILE, payload)
100 +
101 +
102 +def git_output(repo_dir: Path, *args: str) -> str:
103 + completed = subprocess.run(
104 + ["git", "-C", str(repo_dir), *args],
105 + check=True,
106 + text=True,
107 + capture_output=True,
108 + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
109 + )
110 + return completed.stdout.strip()
111 +
112 +
113 +def normalize_describe_to_version(describe: str) -> str:
114 + match = re.fullmatch(r"(.+)-\d+-g[0-9a-f]+", describe)
115 + if match:
116 + return match.group(1)
117 + return describe
118 +
119 +
120 +def get_repo_version_info(repo_dir: Path) -> dict[str, str]:
121 + describe = git_output(repo_dir, "describe", "--tags", "--always")
122 + commit = git_output(repo_dir, "rev-parse", "HEAD")
123 + return {
124 + "describe": describe,
125 + "short_tag": normalize_describe_to_version(describe),
126 + "commit": commit,
127 + "short_commit": commit[:7],
128 + }
129 +
130 +
131 +def normalize_rel_path(value: str | Path) -> str:
132 + normalized = str(value).replace("\\", "/").strip("/")
133 + if normalized in {"", "."}:
134 + return ""
135 + while normalized.startswith("./"):
136 + normalized = normalized[2:]
137 + while "//" in normalized:
138 + normalized = normalized.replace("//", "/")
139 + return normalized.rstrip("/")
140 +
141 +
142 +def is_protected_path(relative_path: str, protected_paths: set[str]) -> bool:
143 + normalized = normalize_rel_path(relative_path)
144 + if not normalized:
145 + return False
146 + return any(
147 + normalized == protected or normalized.startswith(f"{protected}/")
148 + for protected in protected_paths
149 + )
150 +
151 +
152 +def list_protected_paths(repo_dir: Path) -> set[str]:
153 + output = git_output(
154 + repo_dir,
155 + "ls-files",
156 + "--others",
157 + "-i",
158 + "--exclude-standard",
159 + "--directory",
160 + )
161 + protected: set[str] = set()
162 + for raw_line in output.splitlines():
163 + normalized = normalize_rel_path(raw_line)
164 + if not normalized:
165 + continue
166 + current = Path(normalized)
167 + while True:
168 + candidate = normalize_rel_path(current.as_posix())
169 + if candidate:
170 + protected.add(candidate)
171 + if str(current.parent) in {"", "."}:
172 + break
173 + current = current.parent
174 + return protected
175 +
176 +
177 +def remove_path(path: Path) -> None:
178 + if path.is_symlink() or path.is_file():
179 + path.unlink(missing_ok=True)
180 + return
181 + if path.exists():
182 + shutil.rmtree(path)
183 +
184 +
185 +def sync_tree(source_dir: Path, target_dir: Path, *, protected_paths: set[str]) -> None:
186 + target_dir.mkdir(parents=True, exist_ok=True)
187 + protected = {normalize_rel_path(path) for path in protected_paths if path}
188 +
189 + def _sync(src: Path, dst: Path, relative_root: str) -> None:
190 + source_entries = {item.name: item for item in src.iterdir()} if src.exists() else {}
191 + target_entries = {item.name: item for item in dst.iterdir()} if dst.exists() else {}
192 +
193 + for name, src_entry in source_entries.items():
194 + relative_path = normalize_rel_path(Path(relative_root, name).as_posix())
195 + if is_protected_path(relative_path, protected):
196 + continue
197 +
198 + dst_entry = dst / name
199 + if src_entry.is_symlink():
200 + link_target = os.readlink(src_entry)
201 + if dst_entry.is_symlink() and os.readlink(dst_entry) == link_target:
202 + continue
203 + remove_path(dst_entry)
204 + os.symlink(link_target, dst_entry)
205 + continue
206 +
207 + if src_entry.is_dir():
208 + if dst_entry.exists() and not dst_entry.is_dir():
209 + remove_path(dst_entry)
210 + dst_entry.mkdir(parents=True, exist_ok=True)
211 + _sync(src_entry, dst_entry, relative_path)
212 + try:
213 + shutil.copystat(src_entry, dst_entry, follow_symlinks=False)
214 + except OSError:
215 + pass
216 + continue
217 +
218 + if dst_entry.exists() and dst_entry.is_dir():
219 + remove_path(dst_entry)
220 + dst_entry.parent.mkdir(parents=True, exist_ok=True)
221 + shutil.copy2(src_entry, dst_entry, follow_symlinks=False)
222 +
223 + for name, dst_entry in target_entries.items():
224 + relative_path = normalize_rel_path(Path(relative_root, name).as_posix())
225 + if name in source_entries:
226 + continue
227 + if is_protected_path(relative_path, protected):
228 + continue
229 + remove_path(dst_entry)
230 +
231 + _sync(source_dir, target_dir, "")
232 +
233 +
234 +def sanitize_filename(name: str, default_name: str) -> str:
235 + raw = (name or "").strip()
236 + if not raw:
237 + raw = default_name
238 + raw = Path(raw).name
239 + raw = re.sub(r"[^A-Za-z0-9._-]+", "-", raw).strip(".-") or default_name
240 + if not raw.lower().endswith(".zip"):
241 + raw = f"{raw}.zip"
242 + return raw
243 +
244 +
245 +def resolve_backup_destination(
246 + directory: Path,
247 + filename: str,
248 + conflict_policy: str,
249 +) -> Path:
250 + normalized_policy = conflict_policy.strip().lower()
251 + directory.mkdir(parents=True, exist_ok=True)
252 + destination = directory / filename
253 + if not destination.exists():
254 + return destination
255 +
256 + if normalized_policy == "overwrite":
257 + remove_path(destination)
258 + return destination
259 + if normalized_policy == "fail":
260 + raise FileExistsError(f"Backup file already exists: {destination}")
261 + if normalized_policy != "rename":
262 + raise ValueError("backup_conflict_policy must be rename, overwrite, or fail.")
263 +
264 + stem = destination.stem
265 + suffix = destination.suffix
266 + index = 2
267 + while True:
268 + candidate = directory / f"{stem}-{index}{suffix}"
269 + if not candidate.exists():
270 + return candidate
271 + index += 1
272 +
273 +
274 +def create_usr_backup(
275 + *,
276 + repo_dir: Path,
277 + backup_path: str,
278 + backup_name: str,
279 + conflict_policy: str,
280 + logger: AttemptLogger,
281 +) -> Path:
282 + usr_dir = repo_dir / "usr"
283 + if not usr_dir.exists():
284 + raise FileNotFoundError(f"User directory not found: {usr_dir}")
285 +
286 + destination_dir = Path(backup_path)
287 + if not destination_dir.is_absolute():
288 + destination_dir = (repo_dir / destination_dir).resolve()
289 + else:
290 + destination_dir = destination_dir.resolve()
291 + destination_name = sanitize_filename(backup_name, "agent-zero-usr-backup.zip")
292 + destination = resolve_backup_destination(destination_dir, destination_name, conflict_policy)
293 +
294 + temp_fd, temp_path = tempfile.mkstemp(suffix=".zip")
295 + os.close(temp_fd)
296 + temporary_backup = Path(temp_path)
297 +
298 + try:
299 + with zipfile.ZipFile(
300 + temporary_backup,
301 + "w",
302 + compression=zipfile.ZIP_DEFLATED,
303 + compresslevel=6,
304 + ) as archive:
305 + for root, _, files in os.walk(usr_dir):
306 + root_path = Path(root)
307 + for filename in files:
308 + source_file = root_path / filename
309 + archive_name = Path("usr") / source_file.relative_to(usr_dir)
310 + archive.write(source_file, archive_name.as_posix())
311 +
312 + destination.parent.mkdir(parents=True, exist_ok=True)
313 + shutil.move(str(temporary_backup), str(destination))
314 + logger.log(f"Created usr backup at {destination}")
315 + return destination
316 + finally:
317 + if temporary_backup.exists():
318 + temporary_backup.unlink(missing_ok=True)
319 +
320 +
321 +def run_command(command: list[str], *, cwd: Path | None, logger: AttemptLogger) -> None:
322 + logger.log(f"$ {' '.join(command)}")
323 + completed = subprocess.run(
324 + command,
325 + cwd=cwd,
326 + text=True,
327 + capture_output=True,
328 + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
329 + )
330 + if completed.stdout:
331 + logger.log_block("stdout", completed.stdout)
332 + if completed.stderr:
333 + logger.log_block("stderr", completed.stderr)
334 + if completed.returncode != 0:
335 + raise RuntimeError(
336 + f"Command failed with exit code {completed.returncode}: {' '.join(command)}"
337 + )
338 +
339 +
340 +def clone_release(branch: str, tag: str, destination: Path, logger: AttemptLogger) -> None:
341 + logger.log(f"Fetching branch {branch} and tag {tag} from {OFFICIAL_REPO_URL}")
342 + run_command(
343 + [
344 + "git",
345 + "clone",
346 + "--depth",
347 + "1",
348 + "--branch",
349 + branch,
350 + "--single-branch",
351 + OFFICIAL_REPO_URL,
352 + str(destination),
353 + ],
354 + cwd=None,
355 + logger=logger,
356 + )
357 + run_command(
358 + [
359 + "git",
360 + "-C",
361 + str(destination),
362 + "fetch",
363 + "--depth",
364 + "1",
365 + "origin",
366 + f"refs/tags/{tag}:refs/tags/{tag}",
367 + ],
368 + cwd=None,
369 + logger=logger,
370 + )
371 + run_command(
372 + [
373 + "git",
374 + "-C",
375 + str(destination),
376 + "checkout",
377 + "--detach",
378 + f"refs/tags/{tag}",
379 + ],
380 + cwd=None,
381 + logger=logger,
382 + )
383 +
384 +
385 +def launch_ui_process(repo_dir: Path, logger: AttemptLogger) -> subprocess.Popen[bytes]:
386 + prepare_script = repo_dir / "prepare.py"
387 + if prepare_script.exists():
388 + logger.log("Running prepare.py before UI start")
389 + run_command([sys.executable, str(prepare_script), "--dockerized=true"], cwd=repo_dir, logger=logger)
390 + else:
391 + logger.log("prepare.py not found, skipping prepare step")
392 +
393 + logger.log("Starting Agent Zero UI")
394 + return subprocess.Popen(
395 + [
396 + sys.executable,
397 + str(repo_dir / "run_ui.py"),
398 + "--dockerized=true",
399 + "--port=80",
400 + "--host=0.0.0.0",
401 + ],
402 + cwd=repo_dir,
403 + )
404 +
405 +
406 +def wait_for_health(
407 + process: subprocess.Popen[bytes],
408 + *,
409 + health_url: str,
410 + timeout_seconds: int,
411 + poll_interval_seconds: float,
412 + expected_version: str | None = None,
413 + logger: AttemptLogger,
414 +) -> tuple[bool, dict[str, Any] | str]:
415 + deadline = time.monotonic() + timeout_seconds
416 + last_error = "Health check did not return a successful response."
417 +
418 + while time.monotonic() < deadline:
419 + if process.poll() is not None:
420 + return (
421 + False,
422 + f"UI process exited with code {process.returncode} before passing the health check.",
423 + )
424 + try:
425 + request = urllib.request.Request(
426 + health_url,
427 + headers={"Cache-Control": "no-cache"},
428 + method="GET",
429 + )
430 + with urllib.request.urlopen(request, timeout=5) as response:
431 + body = response.read().decode("utf-8")
432 + payload = json.loads(body) if body else {}
433 + git_info = payload.get("gitinfo") or {}
434 + current_version = (git_info.get("short_tag") or "").strip()
435 + if expected_version and current_version and current_version != expected_version:
436 + last_error = (
437 + f"Health check responded, but version {current_version} does not match "
438 + f"expected {expected_version}."
439 + )
440 + elif response.status == 200:
441 + logger.log(f"Health check passed at {health_url}")
442 + return True, payload
443 + except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
444 + last_error = str(exc)
445 +
446 + time.sleep(poll_interval_seconds)
447 +
448 + return False, last_error
449 +
450 +
451 +def terminate_process(process: subprocess.Popen[bytes], timeout_seconds: int = 20) -> None:
452 + if process.poll() is not None:
453 + return
454 + process.terminate()
455 + try:
456 + process.wait(timeout=timeout_seconds)
457 + except subprocess.TimeoutExpired:
458 + process.kill()
459 + process.wait(timeout=5)
460 +
461 +
462 +def wait_for_process(process: subprocess.Popen[bytes]) -> int:
463 + def forward_signal(signum, _frame) -> None:
464 + if process.poll() is None:
465 + process.send_signal(signum)
466 +
467 + for sig in (signal.SIGTERM, signal.SIGINT):
468 + try:
469 + signal.signal(sig, forward_signal)
470 + except ValueError:
471 + pass
472 +
473 + return process.wait()
474 +
475 +
476 +def record_result(
477 + *,
478 + status: str,
479 + message: str,
480 + request_data: dict[str, Any],
481 + source_info: dict[str, str],
482 + current_version: str,
483 + started_at: str,
484 + backup_zip_path: str = "",
485 + rollback_applied: bool = False,
486 + error: str = "",
487 +) -> None:
488 + payload: dict[str, Any] = {
489 + "status": status,
490 + "message": message,
491 + "branch": str(request_data.get("branch", "")),
492 + "tag": str(request_data.get("tag", "")),
493 + "source_version": source_info["short_tag"],
494 + "source_commit": source_info["commit"],
495 + "current_version": current_version,
496 + "requested_at": str(request_data.get("requested_at", "")),
497 + "started_at": started_at,
498 + "finished_at": now_iso(),
499 + "log_file_path": str(LOG_FILE),
500 + "update_file_path": str(TRIGGER_FILE),
501 + "rollback_applied": rollback_applied,
502 + }
503 + if backup_zip_path:
504 + payload["backup_zip_path"] = backup_zip_path
505 + if error:
506 + payload["error"] = error
507 + write_status(payload)
508 +
509 +
510 +def execute_pending_update(
511 + request_data: dict[str, Any],
512 + *,
513 + logger: AttemptLogger,
514 +) -> subprocess.Popen[bytes]:
515 + source_info = get_repo_version_info(REPO_DIR)
516 + started_at = now_iso()
517 + protected_paths = list_protected_paths(REPO_DIR)
518 + temp_root = Path(tempfile.mkdtemp(prefix="a0-self-update-"))
519 + staging_repo = temp_root / "release"
520 + snapshot_dir = temp_root / "snapshot"
521 + backup_zip_path = ""
522 + repository_changed = False
523 + branch = str(request_data.get("branch", "")).strip()
524 + tag = str(request_data.get("tag", "")).strip()
525 +
526 + try:
527 + if not branch:
528 + raise ValueError("Update file is missing the branch field.")
529 + if not tag:
530 + raise ValueError("Update file is missing the tag field.")
531 +
532 + if bool(request_data.get("backup_usr", True)):
533 + backup_zip_path = str(
534 + create_usr_backup(
535 + repo_dir=REPO_DIR,
536 + backup_path=str(request_data.get("backup_path", "/a0/tmp/self-update-backups")),
537 + backup_name=str(request_data.get("backup_name", "agent-zero-usr-backup.zip")),
538 + conflict_policy=str(request_data.get("backup_conflict_policy", "rename")),
539 + logger=logger,
540 + )
541 + )
542 +
543 + logger.log("Creating rollback snapshot")
544 + sync_tree(REPO_DIR, snapshot_dir, protected_paths=protected_paths)
545 +
546 + clone_release(branch, tag, staging_repo, logger)
547 +
548 + logger.log("Applying release into /a0 while preserving ignored paths")
549 + sync_tree(staging_repo, REPO_DIR, protected_paths=protected_paths)
550 + repository_changed = True
551 +
552 + current_info = get_repo_version_info(REPO_DIR)
553 + if current_info["short_tag"] != tag:
554 + raise RuntimeError(
555 + "Release sync completed but the repository version does not match the requested tag. "
556 + f"Expected {tag}, got {current_info['short_tag']}."
557 + )
558 +
559 + updated_process = launch_ui_process(REPO_DIR, logger)
560 + healthy, details = wait_for_health(
561 + updated_process,
562 + health_url=DEFAULT_HEALTH_URL,
563 + timeout_seconds=DEFAULT_HEALTH_TIMEOUT_SECONDS,
564 + poll_interval_seconds=DEFAULT_HEALTH_POLL_INTERVAL_SECONDS,
565 + expected_version=tag,
566 + logger=logger,
567 + )
568 + if healthy:
569 + record_result(
570 + status="success",
571 + message=f"Updated Agent Zero to branch {branch}, tag {tag}.",
572 + request_data=request_data,
573 + source_info=source_info,
574 + current_version=current_info["short_tag"],
575 + started_at=started_at,
576 + backup_zip_path=backup_zip_path,
577 + rollback_applied=False,
578 + )
579 + return updated_process
580 +
581 + logger.log(f"Updated UI failed health check, rolling back: {details}")
582 + terminate_process(updated_process)
583 + sync_tree(snapshot_dir, REPO_DIR, protected_paths=protected_paths)
584 +
585 + rollback_process = launch_ui_process(REPO_DIR, logger)
586 + rollback_healthy, rollback_details = wait_for_health(
587 + rollback_process,
588 + health_url=DEFAULT_HEALTH_URL,
589 + timeout_seconds=DEFAULT_HEALTH_TIMEOUT_SECONDS,
590 + poll_interval_seconds=DEFAULT_HEALTH_POLL_INTERVAL_SECONDS,
591 + expected_version=source_info["short_tag"],
592 + logger=logger,
593 + )
594 +
595 + if rollback_healthy:
596 + record_result(
597 + status="rolled_back",
598 + message=(
599 + "Updated version failed its health check and the previous version was restored. "
600 + f"Reason: {details}"
601 + ),
602 + request_data=request_data,
603 + source_info=source_info,
604 + current_version=source_info["short_tag"],
605 + started_at=started_at,
606 + backup_zip_path=backup_zip_path,
607 + rollback_applied=True,
608 + error=str(details),
609 + )
610 + return rollback_process
611 +
612 + terminate_process(rollback_process)
613 + record_result(
614 + status="rollback_failed",
615 + message=(
616 + "Updated version failed its health check and rollback also failed to become healthy."
617 + ),
618 + request_data=request_data,
619 + source_info=source_info,
620 + current_version=source_info["short_tag"],
621 + started_at=started_at,
622 + backup_zip_path=backup_zip_path,
623 + rollback_applied=True,
624 + error=f"Update error: {details}. Rollback error: {rollback_details}",
625 + )
626 + raise RuntimeError(str(rollback_details))
627 + except Exception as exc:
628 + if repository_changed and snapshot_dir.exists():
629 + logger.log(f"Restoring rollback snapshot after error: {exc}")
630 + sync_tree(snapshot_dir, REPO_DIR, protected_paths=protected_paths)
631 +
632 + record_result(
633 + status="failed" if not repository_changed else "rolled_back",
634 + message=str(exc),
635 + request_data=request_data,
636 + source_info=source_info,
637 + current_version=source_info["short_tag"],
638 + started_at=started_at,
639 + backup_zip_path=backup_zip_path,
640 + rollback_applied=repository_changed,
641 + error=str(exc),
642 + )
643 + logger.log(f"Update flow failed: {exc}")
644 + return launch_ui_process(REPO_DIR, logger)
645 + finally:
646 + shutil.rmtree(temp_root, ignore_errors=True)
647 +
648 +
649 +def load_request_file() -> tuple[dict[str, Any] | None, str]:
650 + if not TRIGGER_FILE.exists():
651 + return None, ""
652 + raw_text = TRIGGER_FILE.read_text(encoding="utf-8")
653 + try:
654 + loaded = yaml.safe_load(raw_text)
655 + return (loaded if isinstance(loaded, dict) else None), raw_text
656 + finally:
657 + TRIGGER_FILE.unlink(missing_ok=True)
658 +
659 +
660 +def docker_run_ui() -> int:
661 + request_data, raw_text = load_request_file()
662 + logger = AttemptLogger(LOG_FILE)
663 + quiet_logger = NullLogger()
664 +
665 + if request_data:
666 + logger.reset()
667 + logger.log(f"Consumed update file at {TRIGGER_FILE}")
668 + logger.log_block("Trigger file content", raw_text)
669 +
670 + try:
671 + current = get_repo_version_info(REPO_DIR)
672 + requested_tag = str(request_data.get("tag", "")).strip()
673 + if requested_tag and current["short_tag"] == requested_tag:
674 + logger.log(
675 + "Requested tag already matches the installed version, skipping file replacement."
676 + )
677 + record_result(
678 + status="skipped",
679 + message="Requested tag already matches the installed version.",
680 + request_data=request_data,
681 + source_info=current,
682 + current_version=current["short_tag"],
683 + started_at=now_iso(),
684 + rollback_applied=False,
685 + )
686 + process = launch_ui_process(REPO_DIR, logger)
687 + else:
688 + process = execute_pending_update(request_data, logger=logger)
689 + except Exception as exc:
690 + logger.log(f"Self-update bootstrap failed unexpectedly: {exc}")
691 + process = launch_ui_process(REPO_DIR, logger)
692 + elif raw_text:
693 + logger.reset()
694 + logger.log(f"Consumed invalid update file at {TRIGGER_FILE}")
695 + logger.log_block("Trigger file content", raw_text)
696 + source_info = get_repo_version_info(REPO_DIR)
697 + record_result(
698 + status="failed",
699 + message="Update file was not valid YAML.",
700 + request_data={},
701 + source_info=source_info,
702 + current_version=source_info["short_tag"],
703 + started_at=now_iso(),
704 + rollback_applied=False,
705 + error="Update file was not valid YAML.",
706 + )
707 + process = launch_ui_process(REPO_DIR, logger)
708 + else:
709 + process = launch_ui_process(REPO_DIR, quiet_logger)
710 +
711 + return wait_for_process(process)
712 +
713 +
714 +def main(argv: list[str] | None = None) -> int:
715 + args = list(argv if argv is not None else sys.argv[1:])
716 + if args and args[0] not in {"docker-run-ui"}:
717 + print(f"Unknown command: {args[0]}", file=sys.stderr)
718 + return 1
719 + return docker_run_ui()
720 +
721 +
722 +if __name__ == "__main__":
723 + raise SystemExit(main())
helpers/self_update.py new
+266
@@ -0,0 +1,266 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import re
5 +import subprocess
6 +from datetime import UTC, datetime
7 +from pathlib import Path
8 +from typing import Any, Literal, TypedDict
9 +
10 +from helpers import git, yaml
11 +
12 +
13 +OFFICIAL_REPO_AUTHOR = "agent0ai"
14 +OFFICIAL_REPO_NAME = "agent-zero"
15 +BRANCH_OPTIONS = [
16 + {"value": "main", "label": "main"},
17 + {"value": "testing", "label": "testing"},
18 + {"value": "development", "label": "development"},
19 +]
20 +BACKUP_CONFLICT_POLICIES = {"rename", "overwrite", "fail"}
21 +
22 +UPDATE_FILE_PATH = Path("/exe/a0-self-update.yaml")
23 +STATUS_FILE_PATH = Path("/exe/a0-self-update-status.yaml")
24 +LOG_FILE_PATH = Path("/exe/a0-self-update.log")
25 +
26 +
27 +class PendingUpdateConfig(TypedDict):
28 + branch: Literal["main", "testing", "development"]
29 + tag: str
30 + source_version: str
31 + source_describe: str
32 + source_commit: str
33 + requested_at: str
34 + backup_usr: bool
35 + backup_path: str
36 + backup_name: str
37 + backup_conflict_policy: Literal["rename", "overwrite", "fail"]
38 +
39 +
40 +class UpdateStatus(TypedDict, total=False):
41 + status: str
42 + message: str
43 + branch: str
44 + tag: str
45 + source_version: str
46 + source_commit: str
47 + current_version: str
48 + requested_at: str
49 + started_at: str
50 + finished_at: str
51 + backup_zip_path: str
52 + log_file_path: str
53 + update_file_path: str
54 + rollback_applied: bool
55 + error: str
56 +
57 +
58 +def _now_iso() -> str:
59 + return datetime.now(UTC).isoformat().replace("+00:00", "Z")
60 +
61 +
62 +def get_update_file_path() -> Path:
63 + return UPDATE_FILE_PATH
64 +
65 +
66 +def get_status_file_path() -> Path:
67 + return STATUS_FILE_PATH
68 +
69 +
70 +def get_log_file_path() -> Path:
71 + return LOG_FILE_PATH
72 +
73 +
74 +def _load_yaml(path: Path) -> dict[str, Any] | None:
75 + if not path.exists():
76 + return None
77 + loaded = yaml.loads(path.read_text(encoding="utf-8"))
78 + return loaded if isinstance(loaded, dict) else None
79 +
80 +
81 +def _write_yaml(path: Path, payload: dict[str, Any]) -> None:
82 + path.parent.mkdir(parents=True, exist_ok=True)
83 + path.write_text(yaml.dumps(payload), encoding="utf-8")
84 +
85 +
86 +def load_pending_update() -> PendingUpdateConfig | None:
87 + loaded = _load_yaml(get_update_file_path())
88 + return loaded if loaded is not None else None
89 +
90 +
91 +def load_last_status() -> UpdateStatus | None:
92 + loaded = _load_yaml(get_status_file_path())
93 + return loaded if loaded is not None else None
94 +
95 +
96 +def get_log_text() -> str:
97 + path = get_log_file_path()
98 + if not path.exists():
99 + return ""
100 + return path.read_text(encoding="utf-8")
101 +
102 +
103 +def get_default_backup_dir(repo_dir: str | Path | None = None) -> Path:
104 + repository = get_repo_dir(repo_dir)
105 + return repository / "tmp" / "self-update-backups"
106 +
107 +
108 +def get_repo_dir(repo_dir: str | Path | None = None) -> Path:
109 + if repo_dir is not None:
110 + return Path(repo_dir).resolve()
111 + return Path(__file__).resolve().parents[1]
112 +
113 +
114 +def _run_git(repo_dir: str | Path, *args: str) -> str:
115 + completed = subprocess.run(
116 + ["git", "-C", str(get_repo_dir(repo_dir)), *args],
117 + check=True,
118 + text=True,
119 + capture_output=True,
120 + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
121 + )
122 + return completed.stdout.strip()
123 +
124 +
125 +def _normalize_describe_to_version(describe: str) -> str:
126 + match = re.fullmatch(r"(.+)-\d+-g[0-9a-f]+", describe)
127 + if match:
128 + return match.group(1)
129 + return describe
130 +
131 +
132 +def get_repo_version_info(repo_dir: str | Path | None = None) -> dict[str, str]:
133 + repository = get_repo_dir(repo_dir)
134 + describe = _run_git(repository, "describe", "--tags", "--always")
135 + commit = _run_git(repository, "rev-parse", "HEAD")
136 + return {
137 + "describe": describe,
138 + "short_tag": _normalize_describe_to_version(describe),
139 + "commit": commit,
140 + "short_commit": commit[:7],
141 + }
142 +
143 +
144 +def _sanitize_filename(name: str, default_name: str) -> str:
145 + raw = (name or "").strip()
146 + if not raw:
147 + raw = default_name
148 + raw = Path(raw).name
149 + raw = re.sub(r"[^A-Za-z0-9._-]+", "-", raw).strip(".-") or default_name
150 + if not raw.lower().endswith(".zip"):
151 + raw = f"{raw}.zip"
152 + return raw
153 +
154 +
155 +def _slugify_version(text: str) -> str:
156 + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", text.strip()).strip("-")
157 + return cleaned or "unknown"
158 +
159 +
160 +def build_default_backup_name(
161 + current_version: str,
162 + target_tag: str | None = None,
163 +) -> str:
164 + timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
165 + current_slug = _slugify_version(current_version)
166 + target_slug = _slugify_version(target_tag or current_version)
167 + return f"agent-zero-usr-{current_slug}-to-{target_slug}-{timestamp}.zip"
168 +
169 +
170 +def _resolve_backup_path(
171 + backup_path: str,
172 + repo_dir: str | Path | None = None,
173 +) -> Path:
174 + raw = (backup_path or "").strip()
175 + if not raw:
176 + return get_default_backup_dir(repo_dir)
177 + path = Path(raw)
178 + if not path.is_absolute():
179 + path = get_repo_dir(repo_dir) / path
180 + return path.resolve()
181 +
182 +
183 +def get_available_tags() -> tuple[list[str], str]:
184 + result = git.get_remote_releases(OFFICIAL_REPO_AUTHOR, OFFICIAL_REPO_NAME)
185 + if result.error:
186 + return [], result.error
187 + return [release.tag for release in result.releases], ""
188 +
189 +
190 +def get_update_info(repo_dir: str | Path | None = None) -> dict[str, Any]:
191 + repository = get_repo_dir(repo_dir)
192 + version_info = get_repo_version_info(repository)
193 + current_version = version_info["short_tag"]
194 + tags, tags_error = get_available_tags()
195 + return {
196 + "repo_dir": str(repository),
197 + "current": version_info,
198 + "pending": load_pending_update(),
199 + "last_status": load_last_status(),
200 + "branches": BRANCH_OPTIONS,
201 + "available_tags": tags,
202 + "available_tags_error": tags_error,
203 + "paths": {
204 + "update_file": str(get_update_file_path()),
205 + "status_file": str(get_status_file_path()),
206 + "log_file": str(get_log_file_path()),
207 + },
208 + "defaults": {
209 + "branch": "main",
210 + "tag": current_version,
211 + "backup_usr": True,
212 + "backup_path": str(get_default_backup_dir(repository)),
213 + "backup_name": build_default_backup_name(current_version, current_version),
214 + "backup_conflict_policy": "rename",
215 + },
216 + }
217 +
218 +
219 +def schedule_update(
220 + *,
221 + branch: str,
222 + tag: str,
223 + backup_usr: bool,
224 + backup_path: str,
225 + backup_name: str,
226 + backup_conflict_policy: str,
227 + repo_dir: str | Path | None = None,
228 +) -> PendingUpdateConfig:
229 + repository = get_repo_dir(repo_dir)
230 + version_info = get_repo_version_info(repository)
231 +
232 + normalized_branch = branch.strip().lower()
233 + if normalized_branch not in {option["value"] for option in BRANCH_OPTIONS}:
234 + raise ValueError("Branch must be one of: main, testing, development.")
235 +
236 + normalized_tag = tag.strip()
237 + if not normalized_tag:
238 + raise ValueError("A release tag is required.")
239 +
240 + normalized_policy = backup_conflict_policy.strip().lower()
241 + if normalized_policy not in BACKUP_CONFLICT_POLICIES:
242 + raise ValueError(
243 + "Backup conflict policy must be one of: rename, overwrite, fail."
244 + )
245 +
246 + resolved_backup_path = _resolve_backup_path(backup_path, repository)
247 + resolved_backup_name = _sanitize_filename(
248 + backup_name,
249 + build_default_backup_name(version_info["short_tag"], normalized_tag),
250 + )
251 +
252 + payload: PendingUpdateConfig = {
253 + "branch": normalized_branch, # type: ignore[assignment]
254 + "tag": normalized_tag,
255 + "source_version": version_info["short_tag"],
256 + "source_describe": version_info["describe"],
257 + "source_commit": version_info["commit"],
258 + "requested_at": _now_iso(),
259 + "backup_usr": bool(backup_usr),
260 + "backup_path": str(resolved_backup_path),
261 + "backup_name": resolved_backup_name,
262 + "backup_conflict_policy": normalized_policy, # type: ignore[assignment]
263 + }
264 +
265 + _write_yaml(get_update_file_path(), payload)
266 + return payload
webui/components/settings/external/self-update-modal.html new
+351
@@ -0,0 +1,351 @@
1 +<html>
2 + <head>
3 + <title>Self Update</title>
4 + <script type="module">
5 + import { store } from "/components/settings/external/self-update-store.js";
6 + </script>
7 + </head>
8 +
9 + <body>
10 + <div x-data>
11 + <template x-if="$store.selfUpdateStore">
12 + <div
13 + x-init="$store.selfUpdateStore.init()"
14 + x-destroy="$store.selfUpdateStore.cleanup()"
15 + class="self-update-modal"
16 + >
17 + <div class="self-update-copy">
18 + <p>
19 + Agent Zero saves this request into
20 + <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>,
21 + restarts once, applies the requested branch and release tag before the UI
22 + starts again, then reloads this page when <code>/api/health</code> is healthy.
23 + </p>
24 + <p>
25 + If the updated UI does not become healthy within 2 minutes, the bootstrap
26 + manager in <code>/exe</code> restores the previous checkout and starts that
27 + version again, so even an older downgraded <code>/a0</code> can be upgraded back
28 + by creating the YAML file manually.
29 + </p>
30 + </div>
31 +
32 + <div class="self-update-summary-grid">
33 + <div class="self-update-summary-card">
34 + <div class="summary-label">Current version</div>
35 + <div class="summary-value" x-text="$store.selfUpdateStore.currentVersion"></div>
36 + <div class="summary-meta" x-text="$store.selfUpdateStore.info?.current?.short_commit || ''"></div>
37 + </div>
38 +
39 + <div class="self-update-summary-card" x-show="$store.selfUpdateStore.info?.pending">
40 + <div class="summary-label">Pending request</div>
41 + <div
42 + class="summary-value"
43 + x-text="$store.selfUpdateStore.formatBranchTag($store.selfUpdateStore.info?.pending?.branch, $store.selfUpdateStore.info?.pending?.tag)"
44 + ></div>
45 + <div
46 + class="summary-meta"
47 + x-text="$store.selfUpdateStore.formatTimestamp($store.selfUpdateStore.info?.pending?.requested_at)"
48 + ></div>
49 + </div>
50 + </div>
51 +
52 + <template x-if="!$store.selfUpdateStore.isSupported">
53 + <div class="self-update-warning">
54 + Self-update is currently available only in dockerized Agent Zero deployments
55 + that boot through <code>/exe/run_A0.sh</code>.
56 + </div>
57 + </template>
58 +
59 + <template x-if="$store.selfUpdateStore.info?.last_status">
60 + <div class="self-update-status-card">
61 + <div class="section-title">Last Attempt</div>
62 + <div class="status-pill" x-text="$store.selfUpdateStore.info?.last_status?.status || 'unknown'"></div>
63 + <div class="status-message" x-text="$store.selfUpdateStore.info?.last_status?.message || ''"></div>
64 + <div class="summary-meta">
65 + Trigger:
66 + <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>
67 + </div>
68 + <div class="summary-meta">
69 + Log:
70 + <code x-text="$store.selfUpdateStore.info?.paths?.log_file || '/exe/a0-self-update.log'"></code>
71 + </div>
72 + <div
73 + class="summary-meta"
74 + x-text="$store.selfUpdateStore.formatTimestamp($store.selfUpdateStore.info?.last_status?.finished_at)"
75 + ></div>
76 + <template x-if="$store.selfUpdateStore.info?.last_status?.backup_zip_path">
77 + <div class="status-path">
78 + Backup:
79 + <code x-text="$store.selfUpdateStore.info?.last_status?.backup_zip_path"></code>
80 + </div>
81 + </template>
82 + </div>
83 + </template>
84 +
85 + <template x-if="$store.selfUpdateStore.isSupported">
86 + <div>
87 + <div class="field">
88 + <div class="field-label">
89 + <div class="field-title">Target branch</div>
90 + <div class="field-description">
91 + Choose which official branch context should be used when resolving the requested tag.
92 + </div>
93 + </div>
94 + <div class="field-control">
95 + <select
96 + x-model="$store.selfUpdateStore.form.branch"
97 + :disabled="$store.selfUpdateStore.isBusy"
98 + >
99 + <template x-for="branch in ($store.selfUpdateStore.info?.branches || [])" :key="branch.value">
100 + <option :value="branch.value" x-text="branch.label"></option>
101 + </template>
102 + </select>
103 + </div>
104 + </div>
105 +
106 + <div class="field">
107 + <div class="field-label">
108 + <div class="field-title">Target release tag</div>
109 + <div class="field-description">
110 + Start typing or pick one of the tags fetched from the official repo. Downgrades are allowed.
111 + </div>
112 + <template x-if="$store.selfUpdateStore.info?.available_tags_error">
113 + <div class="field-description">
114 + Tag lookup failed:
115 + <span x-text="$store.selfUpdateStore.info?.available_tags_error"></span>
116 + </div>
117 + </template>
118 + </div>
119 + <div class="field-control">
120 + <input
121 + type="text"
122 + list="self-update-tag-list"
123 + x-model="$store.selfUpdateStore.form.tag"
124 + placeholder="v0.9.0"
125 + :disabled="$store.selfUpdateStore.isBusy"
126 + />
127 + <datalist id="self-update-tag-list">
128 + <template x-for="tag in $store.selfUpdateStore.availableTags" :key="tag">
129 + <option :value="tag"></option>
130 + </template>
131 + </datalist>
132 + </div>
133 + </div>
134 +
135 + <div class="field">
136 + <div class="field-label">
137 + <div class="field-title">Back up <code>/a0/usr</code> first</div>
138 + <div class="field-description">
139 + Creates a zip backup before the release files are replaced.
140 + </div>
141 + </div>
142 + <div class="field-control">
143 + <label class="toggle">
144 + <input
145 + type="checkbox"
146 + x-model="$store.selfUpdateStore.form.backup_usr"
147 + :disabled="$store.selfUpdateStore.isBusy"
148 + />
149 + <span class="toggler"></span>
150 + </label>
151 + </div>
152 + </div>
153 +
154 + <div x-show="$store.selfUpdateStore.form.backup_usr">
155 + <div class="field">
156 + <div class="field-label">
157 + <div class="field-title">Backup directory</div>
158 + <div class="field-description">
159 + Absolute or repo-relative path where the <code>usr</code> zip should be written.
160 + </div>
161 + </div>
162 + <div class="field-control">
163 + <input
164 + type="text"
165 + x-model="$store.selfUpdateStore.form.backup_path"
166 + :disabled="$store.selfUpdateStore.isBusy"
167 + />
168 + </div>
169 + </div>
170 +
171 + <div class="field">
172 + <div class="field-label">
173 + <div class="field-title">Backup filename</div>
174 + <div class="field-description">
175 + The manager normalizes this into a safe <code>.zip</code> filename.
176 + </div>
177 + </div>
178 + <div class="field-control">
179 + <input
180 + type="text"
181 + x-model="$store.selfUpdateStore.form.backup_name"
182 + :disabled="$store.selfUpdateStore.isBusy"
183 + />
184 + </div>
185 + </div>
186 +
187 + <div class="field">
188 + <div class="field-label">
189 + <div class="field-title">If the filename already exists</div>
190 + <div class="field-description">
191 + Choose whether to rename the backup, replace it, or stop the update.
192 + </div>
193 + </div>
194 + <div class="field-control">
195 + <select
196 + x-model="$store.selfUpdateStore.form.backup_conflict_policy"
197 + :disabled="$store.selfUpdateStore.isBusy"
198 + >
199 + <option value="rename">Rename with suffix</option>
200 + <option value="overwrite">Overwrite existing zip</option>
201 + <option value="fail">Fail before restart</option>
202 + </select>
203 + </div>
204 + </div>
205 + </div>
206 +
207 + <div class="self-update-file-hint">
208 + The durable trigger, status, and log files live outside <code>/a0</code>:
209 + <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>,
210 + <code x-text="$store.selfUpdateStore.info?.paths?.status_file || '/exe/a0-self-update-status.yaml'"></code>,
211 + <code x-text="$store.selfUpdateStore.info?.paths?.log_file || '/exe/a0-self-update.log'"></code>.
212 + </div>
213 + </div>
214 + </template>
215 +
216 + <div class="self-update-error" x-show="$store.selfUpdateStore.error">
217 + <span x-text="$store.selfUpdateStore.error"></span>
218 + </div>
219 +
220 + <div class="self-update-loading" x-show="$store.selfUpdateStore.loading">
221 + Loading update status...
222 + </div>
223 + <div class="self-update-loading" x-show="$store.selfUpdateStore.restarting">
224 + Restarting Agent Zero and waiting for the health check...
225 + </div>
226 +
227 + <div class="modal-footer" data-modal-footer>
228 + <button
229 + class="btn btn-ok"
230 + @click="$store.selfUpdateStore.scheduleUpdate()"
231 + :disabled="!$store.selfUpdateStore.isSupported || $store.selfUpdateStore.isBusy"
232 + >
233 + Schedule Update And Restart
234 + </button>
235 + <button
236 + class="btn btn-field"
237 + @click="$store.selfUpdateStore.refresh()"
238 + :disabled="$store.selfUpdateStore.isBusy"
239 + >
240 + Refresh Status
241 + </button>
242 + <button
243 + class="btn btn-cancel"
244 + @click="$store.selfUpdateStore.close()"
245 + :disabled="$store.selfUpdateStore.restarting"
246 + >
247 + Cancel
248 + </button>
249 + </div>
250 + </div>
251 + </template>
252 + </div>
253 +
254 + <style>
255 + .self-update-modal {
256 + display: flex;
257 + flex-direction: column;
258 + gap: 1rem;
259 + }
260 +
261 + .self-update-copy {
262 + color: var(--color-text-secondary);
263 + line-height: 1.5;
264 + }
265 +
266 + .self-update-copy p {
267 + margin: 0 0 0.75rem;
268 + }
269 +
270 + .self-update-summary-grid {
271 + display: grid;
272 + gap: 0.75rem;
273 + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
274 + }
275 +
276 + .self-update-summary-card,
277 + .self-update-status-card {
278 + border: 1px solid var(--color-border);
279 + border-radius: 10px;
280 + padding: 0.9rem 1rem;
281 + background: var(--color-bg-secondary);
282 + }
283 +
284 + .summary-label {
285 + font-size: 0.8rem;
286 + text-transform: uppercase;
287 + letter-spacing: 0.04em;
288 + color: var(--color-text-secondary);
289 + }
290 +
291 + .summary-value {
292 + font-size: 1.1rem;
293 + font-weight: 600;
294 + margin-top: 0.35rem;
295 + }
296 +
297 + .summary-meta,
298 + .status-path {
299 + margin-top: 0.35rem;
300 + color: var(--color-text-secondary);
301 + font-size: 0.9rem;
302 + word-break: break-word;
303 + }
304 +
305 + .self-update-warning,
306 + .self-update-error,
307 + .self-update-loading {
308 + padding: 0.8rem 0.9rem;
309 + border-radius: 8px;
310 + }
311 +
312 + .self-update-warning {
313 + background: var(--color-warning-bg);
314 + color: var(--color-warning);
315 + }
316 +
317 + .self-update-error {
318 + background: var(--color-error-bg);
319 + color: var(--color-error);
320 + }
321 +
322 + .self-update-loading {
323 + background: var(--color-bg-secondary);
324 + color: var(--color-text-secondary);
325 + }
326 +
327 + .self-update-file-hint {
328 + color: var(--color-text-secondary);
329 + line-height: 1.5;
330 + font-size: 0.95rem;
331 + }
332 +
333 + .status-pill {
334 + display: inline-flex;
335 + align-items: center;
336 + border: 1px solid var(--color-border);
337 + border-radius: 999px;
338 + padding: 0.2rem 0.55rem;
339 + margin-top: 0.35rem;
340 + font-size: 0.82rem;
341 + text-transform: uppercase;
342 + letter-spacing: 0.04em;
343 + }
344 +
345 + .status-message {
346 + margin-top: 0.7rem;
347 + line-height: 1.5;
348 + }
349 + </style>
350 + </body>
351 +</html>
webui/components/settings/external/self-update-store.js new
+210
@@ -0,0 +1,210 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import * as API from "/js/api.js";
3 +import { store as notificationStore } from "/components/notifications/notification-store.js";
4 +
5 +const HEALTH_POLL_INTERVAL_MS = 2000;
6 +const HEALTH_WAIT_BUFFER_MS = 30000;
7 +
8 +const model = {
9 + loading: false,
10 + saving: false,
11 + restarting: false,
12 + error: "",
13 + info: null,
14 + form: {
15 + branch: "main",
16 + tag: "",
17 + backup_usr: true,
18 + backup_path: "",
19 + backup_name: "",
20 + backup_conflict_policy: "rename",
21 + },
22 + _reconnectTimer: null,
23 +
24 + get isBusy() {
25 + return this.loading || this.saving || this.restarting;
26 + },
27 +
28 + get isSupported() {
29 + return Boolean(this.info?.supported);
30 + },
31 +
32 + get currentVersion() {
33 + return this.info?.current?.short_tag || "unknown";
34 + },
35 +
36 + get availableTags() {
37 + return Array.isArray(this.info?.available_tags) ? this.info.available_tags : [];
38 + },
39 +
40 + async init() {
41 + await this.refresh();
42 + },
43 +
44 + cleanup() {
45 + this.clearReconnectTimer();
46 + this.error = "";
47 + this.loading = false;
48 + this.saving = false;
49 + this.restarting = false;
50 + },
51 +
52 + clearReconnectTimer() {
53 + if (this._reconnectTimer) {
54 + clearTimeout(this._reconnectTimer);
55 + this._reconnectTimer = null;
56 + }
57 + },
58 +
59 + formatTimestamp(value) {
60 + if (!value) return "";
61 + try {
62 + return new Date(value).toLocaleString();
63 + } catch {
64 + return value;
65 + }
66 + },
67 +
68 + formatBranchTag(branch, tag) {
69 + return `${branch || "main"} / ${tag || "None"}`;
70 + },
71 +
72 + async refresh() {
73 + this.loading = true;
74 + this.error = "";
75 + try {
76 + const response = await API.callJsonApi("self_update_get", {});
77 + if (!response?.success) {
78 + throw new Error(response?.error || "Failed to load self-update info.");
79 + }
80 + this.info = response;
81 + this.applyFormState(response.pending || response.defaults || {});
82 + } catch (error) {
83 + console.error("Failed to load self-update info:", error);
84 + this.error = error.message || "Failed to load self-update info.";
85 + } finally {
86 + this.loading = false;
87 + }
88 + },
89 +
90 + applyFormState(source) {
91 + this.form.branch = source?.branch || "main";
92 + this.form.tag = source?.tag || this.currentVersion;
93 + this.form.backup_usr =
94 + typeof source?.backup_usr === "boolean" ? source.backup_usr : true;
95 + this.form.backup_path = source?.backup_path || "";
96 + this.form.backup_name = source?.backup_name || "";
97 + this.form.backup_conflict_policy =
98 + source?.backup_conflict_policy || "rename";
99 + },
100 +
101 + async scheduleUpdate() {
102 + if (!this.form.branch?.trim()) {
103 + this.error = "Choose a branch.";
104 + return;
105 + }
106 +
107 + if (!this.form.tag?.trim()) {
108 + this.error = "Enter a release tag to schedule.";
109 + return;
110 + }
111 +
112 + this.saving = true;
113 + this.error = "";
114 + try {
115 + const response = await API.callJsonApi("self_update_schedule", {
116 + branch: this.form.branch,
117 + tag: this.form.tag,
118 + backup_usr: this.form.backup_usr,
119 + backup_path: this.form.backup_path,
120 + backup_name: this.form.backup_name,
121 + backup_conflict_policy: this.form.backup_conflict_policy,
122 + });
123 + if (!response?.success) {
124 + throw new Error(response?.error || "Failed to schedule the self-update.");
125 + }
126 +
127 + if (this.info) {
128 + this.info.pending = response.pending;
129 + }
130 + await notificationStore.frontendWarning(
131 + "Agent Zero is restarting to apply the requested branch and release tag.",
132 + "Self Update",
133 + 10,
134 + "self-update-restart",
135 + undefined,
136 + true,
137 + );
138 + await this.restartAndReload();
139 + } catch (error) {
140 + console.error("Failed to schedule self-update:", error);
141 + this.error = error.message || "Failed to schedule the self-update.";
142 + } finally {
143 + this.saving = false;
144 + }
145 + },
146 +
147 + async restartAndReload() {
148 + this.restarting = true;
149 + this.clearReconnectTimer();
150 +
151 + try {
152 + await API.fetchApi("/restart", {
153 + method: "POST",
154 + headers: {
155 + "Content-Type": "application/json",
156 + },
157 + body: JSON.stringify({}),
158 + });
159 + } catch (_error) {
160 + // The restart request often terminates the backend mid-flight.
161 + }
162 +
163 + const maxWaitMs =
164 + ((this.info?.pending?.health_timeout_seconds ||
165 + this.info?.defaults?.health_timeout_seconds ||
166 + 120) *
167 + 1000) +
168 + HEALTH_WAIT_BUFFER_MS;
169 + const deadline = Date.now() + maxWaitMs;
170 + let lastError = "";
171 +
172 + while (Date.now() < deadline) {
173 + try {
174 + const response = await fetch("/api/health", {
175 + method: "GET",
176 + credentials: "same-origin",
177 + cache: "no-store",
178 + });
179 + if (response.ok) {
180 + window.location.reload();
181 + return;
182 + }
183 + lastError = `Health check returned HTTP ${response.status}.`;
184 + } catch (error) {
185 + lastError = error?.message || String(error);
186 + }
187 +
188 + await new Promise((resolve) => {
189 + this._reconnectTimer = setTimeout(() => {
190 + this._reconnectTimer = null;
191 + resolve();
192 + }, HEALTH_POLL_INTERVAL_MS);
193 + });
194 + }
195 +
196 + this.restarting = false;
197 + this.error =
198 + "Agent Zero did not come back within the expected window. It may still be rolling back. " +
199 + (lastError ? `Last health check error: ${lastError}` : "");
200 + await this.refresh();
201 + },
202 +
203 + close() {
204 + window.closeModal("settings/external/self-update-modal.html");
205 + },
206 +};
207 +
208 +const store = createStore("selfUpdateStore", model);
209 +
210 +export { store };
webui/components/settings/external/update_checker.html
+26
@@ -24,6 +24,32 @@
24 </label>
25 </div>
26 </div>
27 +
28 + <div class="field">
29 + <div class="field-label">
30 + <div class="field-title">Manual Self Update</div>
31 + <div class="field-description">
32 + Schedule a branch plus release tag, optionally zip <code>/a0/usr</code>,
33 + restart Agent Zero, and let the durable bootstrap manager in
34 + <code>/exe</code> either finish the update or automatically roll back if
35 + the UI never becomes healthy.
36 + </div>
37 + <template x-if="!$store.settings.additional?.is_dockerized">
38 + <div class="field-description">
39 + This action is currently available only in dockerized installs.
40 + </div>
41 + </template>
42 + </div>
43 + <div class="field-control">
44 + <button
45 + class="btn btn-field"
46 + @click="openModal('settings/external/self-update-modal.html')"
47 + :disabled="!$store.settings.additional?.is_dockerized"
48 + >
49 + Open Self Update
50 + </button>
51 + </div>
52 + </div>
53 </div>
54 </template>
55 </div>