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>