refactor: comprehensive UI server restructuring and self-update enhancements

- Extract UI server setup into UiServerRuntime class with modular initialization - Move environment configuration, route registration, and transport handlers to helpers/ui_server.py - Add released_at timestamp tracking for git tags and branch heads across update system - Implement get_current_major_main_latest_info to find latest same-major version on main branch - Add major_upgrade_versions and main_branch_latest fields to update info payload - Remove

frdel committed Mar 31, 2026 at 15:20 UTC b94d4b79ae019c1d8409c49c75a65f03c2102ace
8 files changed +1881 -580
helpers/migration.py
+1 -5
@@ -18,9 +18,7 @@ def migrate_user_data() -> None:
18 """
19 Migrate user data from /tmp and other locations to /usr.
20 """
21 -
22 - PrintStyle().print("Checking for data migration...")
23 -
21 +
22 # --- Migrate Directories -------------------------------------------------------
23 # Move directories from tmp/ or other source locations to usr/
24
@@ -54,8 +52,6 @@ def migrate_user_data() -> None:
52 # Remove obsolete directories after migration
53 _cleanup_obsolete()
54
57 - PrintStyle().print("Migration check complete.")
58 -
55
56 def convert_agents_json_yaml() -> None:
57 for root in subagents.get_agents_roots():
helpers/self_update.py
+131 -4
@@ -186,10 +186,27 @@ def _is_latest_selector_tag(tag: str) -> bool:
186 return tag.strip().lower() == "latest"
187
188
189 +def _get_tag_release_time_in_repo(
190 + repo_dir: str | Path,
191 + tag: str,
192 +) -> str:
193 + normalized_tag = tag.strip()
194 + if not normalized_tag:
195 + return ""
196 + try:
197 + timestamp = _run_git(repo_dir, "log", "-1", "--format=%ct", normalized_tag)
198 + if not timestamp:
199 + return ""
200 + return datetime.fromtimestamp(int(timestamp)).strftime("%Y-%m-%d %H:%M:%S")
201 + except Exception:
202 + return ""
203 +
204 +
205 def get_repo_version_info(repo_dir: str | Path | None = None) -> dict[str, str]:
206 repository = get_repo_dir(repo_dir)
207 describe = _run_git(repository, "describe", "--tags", "--always")
208 commit = _run_git(repository, "rev-parse", "HEAD")
209 + short_tag = _normalize_describe_to_version(describe)
210 try:
211 branch = _run_git(repository, "branch", "--show-current")
212 except Exception:
@@ -197,10 +214,11 @@ def get_repo_version_info(repo_dir: str | Path | None = None) -> dict[str, str]:
214 return {
215 "branch": branch,
216 "describe": describe,
200 - "short_tag": _normalize_describe_to_version(describe),
217 + "short_tag": short_tag,
218 "display_version": _format_branch_head_version(branch, describe),
219 "commit": commit,
220 "short_commit": commit[:7],
221 + "released_at": _get_tag_release_time_in_repo(repository, short_tag),
222 }
223
224
@@ -402,7 +420,7 @@ def _get_remote_branch_merged_tags(branch: str) -> set[str]:
420 def _get_remote_branch_head_info(branch: str) -> dict[str, str]:
421 normalized_branch = branch.strip().lower()
422 if _is_excluded_self_update_branch(normalized_branch):
405 - return {"describe": "", "short_tag": "", "commit": ""}
423 + return {"describe": "", "short_tag": "", "commit": "", "released_at": ""}
424
425 cached = _remote_branch_head_cache.get(normalized_branch)
426 now = time.monotonic()
@@ -425,11 +443,14 @@ def _get_remote_branch_head_info(branch: str) -> dict[str, str]:
443 remote_ref = f"refs/remotes/origin/{normalized_branch}"
444 describe = _run_git(repository, "describe", "--tags", "--always", remote_ref)
445 commit = _run_git(repository, "rev-parse", remote_ref)
446 + short_tag = _normalize_describe_to_version(describe)
447 + released_at = _get_tag_release_time_in_repo(repository, short_tag)
448
449 payload = {
450 "describe": describe,
431 - "short_tag": _normalize_describe_to_version(describe),
451 + "short_tag": short_tag,
452 "commit": commit,
453 + "released_at": released_at,
454 }
455 _remote_branch_head_cache[normalized_branch] = (now, payload)
456 return dict(payload)
@@ -464,10 +485,14 @@ def _get_local_branch_head_info(
485 "describe": describe,
486 "short_tag": _normalize_describe_to_version(describe),
487 "commit": commit,
488 + "released_at": _get_tag_release_time_in_repo(
489 + repository,
490 + _normalize_describe_to_version(describe),
491 + ),
492 }
493 except Exception:
494 continue
470 - return {"describe": "", "short_tag": "", "commit": ""}
495 + return {"describe": "", "short_tag": "", "commit": "", "released_at": ""}
496
497
498 def _get_branch_merged_tags(
@@ -555,6 +580,93 @@ def _format_branch_head_version(branch: str, describe: str) -> str:
580 return f"{short_tag}+{commits_since_tag}"
581
582
583 +def _get_release_tag_info(
584 + branch: str,
585 + tag: str,
586 + *,
587 + repo_dir: str | Path | None = None,
588 +) -> dict[str, Any]:
589 + repository = get_repo_dir(repo_dir)
590 + commit = ""
591 + try:
592 + commit = _run_git(repository, "rev-parse", f"refs/tags/{tag}^{{commit}}")
593 + except Exception:
594 + commit = ""
595 + return {
596 + "branch": branch.strip().lower(),
597 + "supported": True,
598 + "describe": tag,
599 + "short_tag": tag,
600 + "display_version": tag,
601 + "commit": commit,
602 + "short_commit": commit[:7] if commit else "",
603 + "released_at": _get_tag_release_time_in_repo(repository, tag),
604 + }
605 +
606 +
607 +def get_current_major_main_latest_info(
608 + current_version: str,
609 + *,
610 + repo_dir: str | Path | None = None,
611 +) -> dict[str, Any]:
612 + repository = get_repo_dir(repo_dir)
613 + available_branches = set(get_available_branch_values(repo_dir=repository))
614 + if "main" not in available_branches:
615 + return {
616 + "branch": "main",
617 + "supported": False,
618 + "describe": "",
619 + "short_tag": "",
620 + "display_version": "",
621 + "commit": "",
622 + "short_commit": "",
623 + "released_at": "",
624 + }
625 +
626 + current_major = _parse_major_version(current_version)
627 + if current_major is None:
628 + return get_current_branch_latest_info("main", repo_dir=repository)
629 +
630 + tags, error = get_available_tags("main", repo_dir=repository)
631 + if error:
632 + return get_current_branch_latest_info("main", repo_dir=repository)
633 +
634 + latest_same_major_tag = next(
635 + (tag for tag in tags if _parse_major_version(tag) == current_major),
636 + "",
637 + )
638 + if not latest_same_major_tag:
639 + return {
640 + "branch": "main",
641 + "supported": True,
642 + "describe": "",
643 + "short_tag": "",
644 + "display_version": "",
645 + "commit": "",
646 + "short_commit": "",
647 + "released_at": "",
648 + }
649 +
650 + branch_head_info = _get_branch_head_info("main", repo_dir=repository)
651 + head_describe = branch_head_info.get("describe", "")
652 + head_short_tag = branch_head_info.get("short_tag", "")
653 + _, commits_since_tag = _split_describe_version(head_describe)
654 + if head_short_tag == latest_same_major_tag and commits_since_tag <= 0:
655 + commit = branch_head_info.get("commit", "")
656 + return {
657 + "branch": "main",
658 + "supported": True,
659 + "describe": head_describe,
660 + "short_tag": head_short_tag,
661 + "display_version": _format_branch_head_version("main", head_describe),
662 + "commit": commit,
663 + "short_commit": commit[:7] if commit else "",
664 + "released_at": branch_head_info.get("released_at", ""),
665 + }
666 +
667 + return _get_release_tag_info("main", latest_same_major_tag, repo_dir=repository)
668 +
669 +
670 def get_current_branch_latest_info(
671 current_branch: str,
672 *,
@@ -572,6 +684,7 @@ def get_current_branch_latest_info(
684 "display_version": "",
685 "commit": "",
686 "short_commit": "",
687 + "released_at": "",
688 }
689
690 branch_head_info = _get_branch_head_info(normalized_branch, repo_dir=repository)
@@ -587,6 +700,7 @@ def get_current_branch_latest_info(
700 ),
701 "commit": commit,
702 "short_commit": commit[:7] if commit else "",
703 + "released_at": branch_head_info.get("released_at", ""),
704 }
705
706
@@ -700,9 +814,21 @@ def get_update_info(repo_dir: str | Path | None = None) -> dict[str, Any]:
814 repo_dir=repository,
815 current_version=current_version,
816 )
817 + if "main" in available_branch_values:
818 + _, major_upgrade_versions, _ = get_selector_tag_options(
819 + "main",
820 + repo_dir=repository,
821 + current_version=current_version,
822 + )
823 + else:
824 + major_upgrade_versions = []
825 return {
826 "repo_dir": str(repository),
827 "current": version_info,
828 + "main_branch_latest": get_current_major_main_latest_info(
829 + current_version,
830 + repo_dir=repository,
831 + ),
832 "current_branch_latest": get_current_branch_latest_info(
833 current_branch,
834 repo_dir=repository,
@@ -714,6 +840,7 @@ def get_update_info(repo_dir: str | Path | None = None) -> dict[str, Any]:
840 "available_tag_options": tag_options,
841 "available_tags_error": tags_error,
842 "available_higher_major_versions": higher_major_versions,
843 + "major_upgrade_versions": major_upgrade_versions,
844 "paths": {
845 "update_file": str(get_update_file_path()),
846 "status_file": str(get_status_file_path()),
helpers/server_startup.py new
+401
@@ -0,0 +1,401 @@
1 +from collections import deque
2 +import asyncio
3 +from contextlib import asynccontextmanager, contextmanager
4 +from dataclasses import dataclass
5 +import faulthandler
6 +import os
7 +import sys
8 +import threading
9 +import time
10 +from typing import Callable, Iterator
11 +import urllib.request
12 +
13 +import uvicorn
14 +
15 +from helpers import process
16 +from helpers.print_style import PrintStyle
17 +
18 +
19 +def _env_int(name: str, default: int, minimum: int = 0) -> int:
20 + try:
21 + return max(minimum, int(os.getenv(name, str(default))))
22 + except (TypeError, ValueError):
23 + return default
24 +
25 +
26 +def _env_float(name: str, default: float, minimum: float = 0.0) -> float:
27 + try:
28 + return max(minimum, float(os.getenv(name, str(default))))
29 + except (TypeError, ValueError):
30 + return default
31 +
32 +
33 +@dataclass(frozen=True)
34 +class StartupConfig:
35 + timeout_seconds: int
36 + max_attempts: int
37 + retry_delay_seconds: float
38 +
39 + @classmethod
40 + def from_env(cls) -> "StartupConfig":
41 + return cls(
42 + timeout_seconds=_env_int("A0_STARTUP_TIMEOUT_SECONDS", 90, minimum=15),
43 + max_attempts=_env_int("A0_STARTUP_MAX_ATTEMPTS", 2, minimum=1),
44 + retry_delay_seconds=_env_float(
45 + "A0_STARTUP_RETRY_DELAY_SECONDS", 2.0, minimum=0.0
46 + ),
47 + )
48 +
49 +
50 +@dataclass
51 +class StartupStageRecord:
52 + name: str
53 + timestamp: float
54 + detail: str | None = None
55 +
56 +
57 +class StartupMonitor:
58 + def __init__(
59 + self,
60 + bind_host: str,
61 + probe_host: str,
62 + port: int,
63 + attempt: int,
64 + max_attempts: int,
65 + timeout_seconds: int,
66 + ) -> None:
67 + self.bind_host = bind_host
68 + self.probe_host = probe_host
69 + self.port = port
70 + self.attempt = attempt
71 + self.max_attempts = max_attempts
72 + self.timeout_seconds = timeout_seconds
73 + self.start_time = time.monotonic()
74 + self._stage = "created"
75 + self._stage_detail: str | None = None
76 + self._stage_started_at = self.start_time
77 + self._history: deque[StartupStageRecord] = deque(maxlen=30)
78 + self._history.append(StartupStageRecord(self._stage, self.start_time))
79 + self._ready = threading.Event()
80 + self._stop = threading.Event()
81 + self._lock = threading.RLock()
82 + self._server: uvicorn.Server | None = None
83 + self._watchdog_thread: threading.Thread | None = None
84 +
85 + def _prefix(self) -> str:
86 + return f"[startup attempt {self.attempt}/{self.max_attempts}]"
87 +
88 + def mark(self, stage: str, detail: str | None = None) -> None:
89 + now = time.monotonic()
90 + with self._lock:
91 + self._stage = stage
92 + self._stage_detail = detail
93 + self._stage_started_at = now
94 + self._history.append(StartupStageRecord(stage, now, detail))
95 + elapsed = now - self.start_time
96 +
97 + suffix = f" ({detail})" if detail else ""
98 + PrintStyle.debug(f"{self._prefix()} {stage}{suffix} at +{elapsed:.1f}s")
99 +
100 + @contextmanager
101 + def stage(self, stage: str, detail: str | None = None) -> Iterator[None]:
102 + self.mark(f"{stage}.start", detail)
103 + try:
104 + yield
105 + except BaseException as e:
106 + message = f"{type(e).__name__}: {e}"
107 + self.mark(f"{stage}.error", message[:200])
108 + raise
109 + else:
110 + self.mark(f"{stage}.done", detail)
111 +
112 + def lifespan(self):
113 + @asynccontextmanager
114 + async def _lifespan(_app):
115 + self.mark("starlette.lifespan.startup")
116 + try:
117 + yield
118 + finally:
119 + self.mark("starlette.lifespan.shutdown")
120 +
121 + return _lifespan
122 +
123 + def attach_server(self, server: uvicorn.Server) -> None:
124 + with self._lock:
125 + self._server = server
126 +
127 + def start_watchdog(self) -> None:
128 + if self._watchdog_thread and self._watchdog_thread.is_alive():
129 + return
130 + self._watchdog_thread = threading.Thread(
131 + target=self._watchdog_loop,
132 + daemon=True,
133 + name=f"StartupWatchdog-{self.attempt}",
134 + )
135 + self._watchdog_thread.start()
136 +
137 + def mark_ready(self, source: str = "health_check") -> None:
138 + if self._ready.is_set():
139 + return
140 + self.mark("ready", source)
141 + self._ready.set()
142 + self._stop.set()
143 +
144 + def is_ready(self) -> bool:
145 + return self._ready.is_set()
146 +
147 + def close(self) -> None:
148 + self._stop.set()
149 +
150 + def stop_event(self) -> threading.Event:
151 + return self._stop
152 +
153 + def _watchdog_loop(self) -> None:
154 + next_progress_log = self.start_time + 10
155 + while not self._stop.wait(timeout=1):
156 + if self._ready.is_set():
157 + return
158 +
159 + now = time.monotonic()
160 + if now >= next_progress_log:
161 + stage, detail, stage_elapsed, total_elapsed, _history = self.snapshot()
162 + detail_text = f" ({detail})" if detail else ""
163 + PrintStyle.warning(
164 + f"{self._prefix()} still waiting for readiness after "
165 + f"{total_elapsed:.1f}s; current stage '{stage}' has been active "
166 + f"for {stage_elapsed:.1f}s{detail_text}"
167 + )
168 + next_progress_log = now + 10
169 +
170 + if now - self.start_time >= self.timeout_seconds:
171 + self._handle_timeout()
172 + return
173 +
174 + def snapshot(
175 + self,
176 + ) -> tuple[str, str | None, float, float, list[StartupStageRecord]]:
177 + now = time.monotonic()
178 + with self._lock:
179 + return (
180 + self._stage,
181 + self._stage_detail,
182 + now - self._stage_started_at,
183 + now - self.start_time,
184 + list(self._history),
185 + )
186 +
187 + def _handle_timeout(self) -> None:
188 + stage, detail, stage_elapsed, total_elapsed, history = self.snapshot()
189 + detail_text = f" ({detail})" if detail else ""
190 + PrintStyle.error(
191 + f"{self._prefix()} startup timed out after {total_elapsed:.1f}s while "
192 + f"waiting for bind={self.bind_host}:{self.port} "
193 + f"probe=http://{self.probe_host}:{self.port}/api/health; current stage "
194 + f"'{stage}' has been active for {stage_elapsed:.1f}s{detail_text}"
195 + )
196 +
197 + PrintStyle.error(f"{self._prefix()} recent stage history follows:")
198 + for record in history:
199 + relative = record.timestamp - self.start_time
200 + suffix = f" ({record.detail})" if record.detail else ""
201 + PrintStyle.standard(f" +{relative:5.1f}s {record.name}{suffix}")
202 +
203 + active_threads = ", ".join(
204 + f"{thread.name}(alive={thread.is_alive()}, daemon={thread.daemon})"
205 + for thread in threading.enumerate()
206 + )
207 + PrintStyle.error(f"{self._prefix()} active threads: {active_threads}")
208 + PrintStyle.error(
209 + f"{self._prefix()} dumping all thread stack traces for startup diagnosis"
210 + )
211 + try:
212 + faulthandler.dump_traceback(file=sys.stderr, all_threads=True)
213 + except Exception as e:
214 + PrintStyle.error(f"{self._prefix()} failed to dump thread traces: {e}")
215 +
216 + with self._lock:
217 + server = self._server
218 +
219 + if server is not None:
220 + PrintStyle.warning(
221 + f"{self._prefix()} requesting uvicorn shutdown after startup timeout"
222 + )
223 + server.should_exit = True
224 +
225 + if not self._stop.wait(timeout=3):
226 + PrintStyle.error(
227 + f"{self._prefix()} forcing process exit so the supervisor can restart it"
228 + )
229 + os._exit(1)
230 +
231 +
232 +def get_health_probe_host(bind_host: str) -> str:
233 + if bind_host in {"0.0.0.0", "::", "[::]", ""}:
234 + return "127.0.0.1"
235 + return bind_host
236 +
237 +
238 +def run_uvicorn_with_retries(
239 + *,
240 + host: str,
241 + port: int,
242 + build_asgi_app: Callable[[StartupMonitor], object],
243 + flush_callback: Callable[[str], None],
244 + access_log: bool = False,
245 + log_level: str = "info",
246 + ws: str = "wsproto",
247 + startup_config: StartupConfig | None = None,
248 +) -> None:
249 + startup_config = startup_config or StartupConfig.from_env()
250 + health_host = get_health_probe_host(host)
251 + PrintStyle.debug(
252 + f"[startup] bind={host}:{port} probe=http://{health_host}:{port}/api/health "
253 + f"timeout={startup_config.timeout_seconds}s attempts={startup_config.max_attempts}"
254 + )
255 +
256 + for attempt in range(1, startup_config.max_attempts + 1):
257 + startup_monitor = StartupMonitor(
258 + bind_host=host,
259 + probe_host=health_host,
260 + port=port,
261 + attempt=attempt,
262 + max_attempts=startup_config.max_attempts,
263 + timeout_seconds=startup_config.timeout_seconds,
264 + )
265 + try:
266 + if _run_server_attempt(
267 + host=host,
268 + health_host=health_host,
269 + port=port,
270 + startup_monitor=startup_monitor,
271 + build_asgi_app=build_asgi_app,
272 + flush_callback=flush_callback,
273 + access_log=access_log,
274 + log_level=log_level,
275 + ws=ws,
276 + ):
277 + return
278 + except BaseException as e:
279 + if isinstance(e, SystemExit) and startup_monitor.is_ready():
280 + raise
281 +
282 + PrintStyle.error(
283 + f"[startup attempt {attempt}/{startup_config.max_attempts}] "
284 + f"server startup failed before readiness with "
285 + f"{type(e).__name__}: {e}"
286 + )
287 + if attempt >= startup_config.max_attempts:
288 + raise
289 + else:
290 + if attempt >= startup_config.max_attempts:
291 + raise RuntimeError(
292 + "Uvicorn exited before readiness on the final startup attempt."
293 + )
294 +
295 + PrintStyle.warning(
296 + f"[startup attempt {attempt}/{startup_config.max_attempts}] "
297 + "server exited before readiness; retrying"
298 + )
299 +
300 + if startup_config.retry_delay_seconds > 0:
301 + PrintStyle.warning(
302 + f"[startup attempt {attempt}/{startup_config.max_attempts}] "
303 + f"sleeping {startup_config.retry_delay_seconds:.1f}s before retry"
304 + )
305 + time.sleep(startup_config.retry_delay_seconds)
306 +
307 + raise RuntimeError("Server failed to reach readiness after all startup attempts.")
308 +
309 +
310 +def _run_server_attempt(
311 + *,
312 + host: str,
313 + health_host: str,
314 + port: int,
315 + startup_monitor: StartupMonitor,
316 + build_asgi_app: Callable[[StartupMonitor], object],
317 + flush_callback: Callable[[str], None],
318 + access_log: bool,
319 + log_level: str,
320 + ws: str,
321 +) -> bool:
322 + startup_monitor.start_watchdog()
323 + try:
324 + asgi_app = build_asgi_app(startup_monitor)
325 +
326 + with startup_monitor.stage("uvicorn.config.create"):
327 + config = uvicorn.Config(
328 + asgi_app,
329 + host=host,
330 + port=port,
331 + log_level=log_level,
332 + access_log=access_log,
333 + ws=ws,
334 + )
335 +
336 + with startup_monitor.stage("uvicorn.server.create"):
337 + server = uvicorn.Server(config)
338 +
339 + startup_monitor.attach_server(server)
340 + process.set_server(_UvicornServerWrapper(server, flush_callback))
341 +
342 + startup_monitor.mark("health.thread.start")
343 + threading.Thread(
344 + target=wait_for_health,
345 + args=(health_host, port, startup_monitor),
346 + daemon=True,
347 + name=f"StartupHealth-{startup_monitor.attempt}",
348 + ).start()
349 +
350 + PrintStyle().debug(f"Starting server at http://{host}:{port} ...")
351 + startup_monitor.mark("uvicorn.run.enter")
352 + _serve_uvicorn(server)
353 +
354 + if startup_monitor.is_ready():
355 + return True
356 +
357 + PrintStyle.warning(
358 + f"[startup attempt {startup_monitor.attempt}/{startup_monitor.max_attempts}] "
359 + "uvicorn exited before the health probe observed readiness"
360 + )
361 + return False
362 + finally:
363 + startup_monitor.close()
364 + process.set_server(None)
365 + flush_callback("server_exit")
366 +
367 +
368 +def wait_for_health(host: str, port: int, startup_monitor: StartupMonitor) -> None:
369 + url = f"http://{host}:{port}/api/health"
370 + while not startup_monitor.stop_event().is_set():
371 + try:
372 + with urllib.request.urlopen(url, timeout=2) as resp:
373 + if resp.status == 200:
374 + startup_monitor.mark_ready("health_probe")
375 + PrintStyle().print("Agent Zero is running.")
376 + return
377 + except Exception:
378 + pass
379 + startup_monitor.stop_event().wait(1)
380 +
381 +
382 +class _UvicornServerWrapper:
383 + def __init__(
384 + self, server: uvicorn.Server, flush_callback: Callable[[str], None]
385 + ) -> None:
386 + self._server = server
387 + self._flush_callback = flush_callback
388 +
389 + def shutdown(self) -> None:
390 + self._flush_callback("shutdown")
391 + self._server.should_exit = True
392 +
393 +
394 +def _serve_uvicorn(server: uvicorn.Server) -> None:
395 + # Avoid uvicorn.Server.run(), which delegates to asyncio.run(...) and can
396 + # conflict with the global nest_asyncio patch used by the runtime.
397 + # The project requires uvicorn>=0.38.0, where loop setup is exposed via
398 + # Config.get_loop_factory().
399 + loop_factory = server.config.get_loop_factory()
400 + with asyncio.Runner(loop_factory=loop_factory) as runner:
401 + runner.run(server.serve())
helpers/ui_server.py new
+283
@@ -0,0 +1,283 @@
1 +from dataclasses import dataclass, field
2 +from datetime import timedelta
3 +import asyncio
4 +import logging
5 +import os
6 +import secrets
7 +import threading
8 +import time
9 +from typing import Any
10 +
11 +from flask import (
12 + Flask,
13 + Response,
14 + redirect,
15 + render_template_string,
16 + request,
17 + send_file,
18 + session,
19 + url_for,
20 +)
21 +from socketio import ASGIApp
22 +from starlette.applications import Starlette
23 +from starlette.routing import Mount
24 +from uvicorn.middleware.wsgi import WSGIMiddleware
25 +from werkzeug.wrappers.request import Request as WerkzeugRequest
26 +import socketio # type: ignore[import-untyped]
27 +
28 +from helpers import dotenv, fasta2a_server, files, git, login, mcp_server, runtime
29 +from helpers.api import register_api_route, requires_auth
30 +from helpers.extension import extensible
31 +from helpers.files import get_abs_path
32 +from helpers.print_style import PrintStyle
33 +from helpers.server_startup import StartupMonitor
34 +from helpers import settings as settings_helper
35 +from helpers.ws import register_ws_namespace, validate_ws_origin
36 +from helpers.ws_manager import WsManager, set_shared_ws_manager
37 +
38 +
39 +UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 * 1024
40 +
41 +
42 +def configure_process_environment() -> None:
43 + logging.getLogger().setLevel(logging.WARNING)
44 + os.environ["TZ"] = "UTC"
45 + os.environ["TOKENIZERS_PARALLELISM"] = "false"
46 + if hasattr(time, "tzset"):
47 + time.tzset()
48 +
49 +
50 +@dataclass
51 +class UiServerRuntime:
52 + webapp: Flask
53 + socketio_server: socketio.AsyncServer
54 + ws_manager: WsManager
55 + lock: threading.RLock
56 + settings_snapshot: dict[str, Any]
57 + _routes_registered: bool = False
58 + _transport_registered: bool = False
59 + _route_handlers: "UiRouteHandlers | None" = field(default=None, init=False)
60 +
61 + @classmethod
62 + def create(cls) -> "UiServerRuntime":
63 + webapp = Flask("app", static_folder=get_abs_path("./webui"), static_url_path="/")
64 + webapp.secret_key = os.getenv("FLASK_SECRET_KEY") or secrets.token_hex(32)
65 +
66 + WerkzeugRequest.max_form_memory_size = UPLOAD_LIMIT_BYTES
67 + webapp.config.update(
68 + JSON_SORT_KEYS=False,
69 + SESSION_COOKIE_NAME="session_" + runtime.get_runtime_id(),
70 + SESSION_COOKIE_SAMESITE="Lax",
71 + SESSION_PERMANENT=True,
72 + PERMANENT_SESSION_LIFETIME=timedelta(days=1),
73 + MAX_CONTENT_LENGTH=int(
74 + os.getenv("FLASK_MAX_CONTENT_LENGTH", str(UPLOAD_LIMIT_BYTES))
75 + ),
76 + MAX_FORM_MEMORY_SIZE=int(
77 + os.getenv("FLASK_MAX_FORM_MEMORY_SIZE", str(UPLOAD_LIMIT_BYTES))
78 + ),
79 + )
80 +
81 + lock = threading.RLock()
82 + socketio_server = socketio.AsyncServer(
83 + async_mode="asgi",
84 + namespaces="*",
85 + cors_allowed_origins=lambda _origin, environ: validate_ws_origin(environ)[0],
86 + logger=False,
87 + engineio_logger=False,
88 + ping_interval=25,
89 + ping_timeout=20,
90 + max_http_buffer_size=50 * 1024 * 1024,
91 + )
92 +
93 + ws_manager = WsManager(socketio_server, lock)
94 + set_shared_ws_manager(ws_manager)
95 +
96 + server_runtime = cls(
97 + webapp=webapp,
98 + socketio_server=socketio_server,
99 + ws_manager=ws_manager,
100 + lock=lock,
101 + settings_snapshot={},
102 + )
103 + server_runtime.refresh_runtime_settings()
104 + return server_runtime
105 +
106 + def refresh_runtime_settings(self) -> None:
107 + self.settings_snapshot = settings_helper.get_settings()
108 + settings_helper.set_runtime_settings_snapshot(self.settings_snapshot)
109 + self.ws_manager.set_server_restart_broadcast(
110 + self.settings_snapshot.get("websocket_server_restart_enabled", True)
111 + )
112 +
113 + def register_http_routes(self) -> None:
114 + if self._routes_registered:
115 + return
116 +
117 + handlers = UiRouteHandlers(self)
118 + self._route_handlers = handlers
119 + self.webapp.add_url_rule(
120 + "/login",
121 + "login_handler",
122 + handlers.login_handler,
123 + methods=["GET", "POST"],
124 + )
125 + self.webapp.add_url_rule(
126 + "/logout",
127 + "logout_handler",
128 + handlers.logout_handler,
129 + methods=["GET"],
130 + )
131 + self.webapp.add_url_rule(
132 + "/",
133 + "serve_index",
134 + handlers.serve_index,
135 + methods=["GET"],
136 + )
137 + self.webapp.add_url_rule(
138 + "/plugins/<plugin_name>/<path:asset_path>",
139 + "serve_builtin_plugin_asset",
140 + handlers.serve_builtin_plugin_asset,
141 + methods=["GET"],
142 + )
143 + self.webapp.add_url_rule(
144 + "/usr/plugins/<plugin_name>/<path:asset_path>",
145 + "serve_plugin_asset",
146 + handlers.serve_plugin_asset,
147 + methods=["GET"],
148 + )
149 + self.webapp.add_url_rule(
150 + "/extensions/webui/<path:asset_path>",
151 + "serve_extension_asset",
152 + handlers.serve_extension_asset,
153 + methods=["GET"],
154 + )
155 + self._routes_registered = True
156 +
157 + def register_transport_handlers(self) -> None:
158 + if self._transport_registered:
159 + return
160 + register_api_route(self.webapp, self.lock)
161 + register_ws_namespace(
162 + self.socketio_server,
163 + self.webapp,
164 + self.lock,
165 + manager=self.ws_manager,
166 + )
167 + self._transport_registered = True
168 +
169 + def build_asgi_app(self, startup_monitor: StartupMonitor):
170 + with startup_monitor.stage("wsgi.middleware.create"):
171 + wsgi_app = WSGIMiddleware(self.webapp)
172 +
173 + with startup_monitor.stage("mcp.proxy.init"):
174 + mcp_app = mcp_server.DynamicMcpProxy.get_instance()
175 +
176 + with startup_monitor.stage("a2a.proxy.init"):
177 + a2a_app = fasta2a_server.DynamicA2AProxy.get_instance()
178 +
179 + with startup_monitor.stage("starlette.app.create"):
180 + starlette_app = Starlette(
181 + routes=[
182 + Mount("/mcp", app=mcp_app),
183 + Mount("/a2a", app=a2a_app),
184 + Mount("/", app=wsgi_app),
185 + ],
186 + lifespan=startup_monitor.lifespan(),
187 + )
188 +
189 + with startup_monitor.stage("socketio.asgi.create"):
190 + return ASGIApp(self.socketio_server, other_asgi_app=starlette_app)
191 +
192 + def access_log_enabled(self) -> bool:
193 + return self.settings_snapshot.get("uvicorn_access_logs_enabled", False)
194 +
195 +
196 +class UiRouteHandlers:
197 + def __init__(self, runtime_state: UiServerRuntime) -> None:
198 + self.runtime = runtime_state
199 +
200 + @extensible
201 + async def login_handler(self):
202 + error = None
203 + if request.method == "POST":
204 + user = dotenv.get_dotenv_value("AUTH_LOGIN")
205 + password = dotenv.get_dotenv_value("AUTH_PASSWORD")
206 +
207 + if request.form["username"] == user and request.form["password"] == password:
208 + session["authentication"] = login.get_credentials_hash()
209 + return redirect(url_for("serve_index"))
210 + else:
211 + await asyncio.sleep(1)
212 + error = "Invalid Credentials. Please try again."
213 +
214 + login_page_content = files.read_file("webui/login.html")
215 + return render_template_string(login_page_content, error=error)
216 +
217 + @extensible
218 + async def logout_handler(self):
219 + session.pop("authentication", None)
220 + return redirect(url_for("login_handler"))
221 +
222 + @requires_auth
223 + @extensible
224 + async def serve_index(self):
225 + try:
226 + gitinfo = git.get_git_info()
227 + except Exception:
228 + gitinfo = {
229 + "version": "unknown",
230 + "commit_time": "unknown",
231 + }
232 +
233 + index = files.read_file("webui/index.html")
234 + return files.replace_placeholders_text(
235 + _content=index,
236 + version_no=gitinfo["version"],
237 + version_time=gitinfo["commit_time"],
238 + runtime_id=runtime.get_runtime_id(),
239 + runtime_is_development=("true" if runtime.is_development() else "false"),
240 + logged_in=("true" if login.get_credentials_hash() else "false"),
241 + )
242 +
243 + @requires_auth
244 + async def serve_builtin_plugin_asset(self, plugin_name, asset_path):
245 + return await self._serve_plugin_asset(plugin_name, asset_path)
246 +
247 + @requires_auth
248 + async def serve_plugin_asset(self, plugin_name, asset_path):
249 + return await self._serve_plugin_asset(plugin_name, asset_path)
250 +
251 + @requires_auth
252 + async def serve_extension_asset(self, asset_path):
253 + exts = files.get_abs_path("extensions/webui")
254 + path = files.get_abs_path(exts, asset_path)
255 + if not files.is_in_dir(path, exts):
256 + return Response("Access denied", 403)
257 + return send_file(path)
258 +
259 + @extensible
260 + async def _serve_plugin_asset(self, plugin_name, asset_path):
261 + from helpers import plugins
262 +
263 + plugin_dir = plugins.find_plugin_dir(plugin_name)
264 + if not plugin_dir:
265 + return Response("Plugin not found", 404)
266 +
267 + try:
268 + asset_file = files.get_abs_path(plugin_dir, asset_path)
269 + webui_dir = files.get_abs_path(plugin_dir, "webui")
270 + webui_extensions_dir = files.get_abs_path(plugin_dir, "extensions/webui")
271 +
272 + if not files.is_in_dir(str(asset_file), str(webui_dir)) and not files.is_in_dir(
273 + str(asset_file), str(webui_extensions_dir)
274 + ):
275 + return Response("Access denied", 403)
276 +
277 + if not files.is_file(asset_file):
278 + return Response("Asset not found", 404)
279 +
280 + return send_file(str(asset_file))
281 + except Exception as e:
282 + PrintStyle.error(f"Error serving plugin asset: {e}")
283 + return Response("Error serving asset", 500)
run_ui.py
+32 -255
@@ -1,242 +1,62 @@
1 -from datetime import timedelta
2 -import os
3 -import secrets
4 -import time
5 -import threading
6 -import asyncio
7 -
8 -import urllib.request
9 -import uvicorn
10 -from flask import Flask, request, Response, session, redirect, url_for, render_template_string
11 -from werkzeug.wrappers.request import Request as WerkzeugRequest
12 -
1 import initialize
14 -from helpers import files, git, mcp_server, fasta2a_server, settings as settings_helper, extension
15 -from helpers.files import get_abs_path
16 -from helpers import runtime, dotenv, process
17 -from helpers.api import register_api_route, requires_auth, csrf_protect
18 -from helpers.ws import register_ws_namespace, validate_ws_origin
2 +from helpers import dotenv, extension, runtime
3 from helpers.print_style import PrintStyle
20 -from helpers import login
21 -import socketio # type: ignore[import-untyped]
22 -from socketio import ASGIApp, packet
23 -from starlette.applications import Starlette
24 -from starlette.routing import Mount
25 -from uvicorn.middleware.wsgi import WSGIMiddleware
26 -from helpers.ws_manager import WsManager, set_shared_ws_manager
27 -from flask import send_file
28 -
29 -# disable logging
30 -import logging
31 -logging.getLogger().setLevel(logging.WARNING)
32 -
33 -
34 -# Set the new timezone to 'UTC'
35 -os.environ["TZ"] = "UTC"
36 -os.environ["TOKENIZERS_PARALLELISM"] = "false"
37 -# Apply the timezone change
38 -if hasattr(time, 'tzset'):
39 - time.tzset()
40 -
41 -# initialize the internal Flask server
42 -webapp = Flask("app", static_folder=get_abs_path("./webui"), static_url_path="/")
43 -webapp.secret_key = os.getenv("FLASK_SECRET_KEY") or secrets.token_hex(32)
44 -
45 -UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 * 1024
46 -
47 -# Werkzeug's default max_form_memory_size is 500_000 bytes which can trigger 413 for multipart requests
48 -# with larger non-file fields. Raise it to match our intended upload limit.
49 -WerkzeugRequest.max_form_memory_size = UPLOAD_LIMIT_BYTES
50 -
51 -webapp.config.update(
52 - JSON_SORT_KEYS=False,
53 - SESSION_COOKIE_NAME="session_" + runtime.get_runtime_id(), # bind the session cookie name to runtime id to prevent session collision on same host
54 - SESSION_COOKIE_SAMESITE="Lax",
55 - SESSION_PERMANENT=True,
56 - PERMANENT_SESSION_LIFETIME=timedelta(days=1),
57 - MAX_CONTENT_LENGTH=int(os.getenv("FLASK_MAX_CONTENT_LENGTH", str(UPLOAD_LIMIT_BYTES))),
58 - MAX_FORM_MEMORY_SIZE=int(os.getenv("FLASK_MAX_FORM_MEMORY_SIZE", str(UPLOAD_LIMIT_BYTES))),
59 -)
60 -
61 -lock = threading.RLock()
62 -
63 -socketio_server = socketio.AsyncServer(
64 - async_mode="asgi",
65 - namespaces="*",
66 - cors_allowed_origins=lambda _origin, environ: validate_ws_origin(environ)[0],
67 - logger=False,
68 - engineio_logger=False,
69 - ping_interval=25, # explicit default to avoid future lib changes
70 - ping_timeout=20, # explicit default to avoid future lib changes
71 - max_http_buffer_size=50 * 1024 * 1024,
72 -)
73 -
74 -ws_manager = WsManager(socketio_server, lock)
75 -set_shared_ws_manager(ws_manager)
76 -_settings = settings_helper.get_settings()
77 -settings_helper.set_runtime_settings_snapshot(_settings)
78 -ws_manager.set_server_restart_broadcast(
79 - _settings.get("websocket_server_restart_enabled", True)
80 -)
81 -
82 -# Set up basic authentication for UI and API but not MCP
83 -# basic_auth = BasicAuth(webapp)
84 -
85 -
86 -@webapp.route("/login", methods=["GET", "POST"])
87 -@extension.extensible
88 -async def login_handler():
89 - error = None
90 - if request.method == 'POST':
91 - user = dotenv.get_dotenv_value("AUTH_LOGIN")
92 - password = dotenv.get_dotenv_value("AUTH_PASSWORD")
93 -
94 - if request.form['username'] == user and request.form['password'] == password:
95 - session['authentication'] = login.get_credentials_hash()
96 - return redirect(url_for('serve_index'))
97 - else:
98 - await asyncio.sleep(1)
99 - error = 'Invalid Credentials. Please try again.'
100 -
101 - login_page_content = files.read_file("webui/login.html")
102 - return render_template_string(login_page_content, error=error)
103 -
104 -
105 -@webapp.route("/logout")
106 -@extension.extensible
107 -async def logout_handler():
108 - session.pop('authentication', None)
109 - return redirect(url_for('login_handler'))
110 -
111 -
112 -# handle default address, load index
113 -@webapp.route("/", methods=["GET"])
114 -@requires_auth
115 -@extension.extensible
116 -async def serve_index():
117 - gitinfo = None
118 - try:
119 - gitinfo = git.get_git_info()
120 - except Exception:
121 - gitinfo = {
122 - "version": "unknown",
123 - "commit_time": "unknown",
124 - }
125 - index = files.read_file("webui/index.html")
126 - index = files.replace_placeholders_text(
127 - _content=index,
128 - version_no=gitinfo["version"],
129 - version_time=gitinfo["commit_time"],
130 - runtime_id=runtime.get_runtime_id(),
131 - runtime_is_development=("true" if runtime.is_development() else "false"),
132 - logged_in=("true" if login.get_credentials_hash() else "false"),
133 - )
134 - return index
135 -
4 +from helpers.server_startup import run_uvicorn_with_retries
5 +from helpers.ui_server import UiServerRuntime, configure_process_environment
6
137 -# Serve plugin assets
138 -@webapp.route("/plugins/<plugin_name>/<path:asset_path>", methods=["GET"])
139 -@requires_auth
140 -async def serve_builtin_plugin_asset(plugin_name, asset_path):
141 - return await _serve_plugin_asset(plugin_name, asset_path)
7
143 -@webapp.route("/usr/plugins/<plugin_name>/<path:asset_path>", methods=["GET"])
144 -@requires_auth
145 -async def serve_plugin_asset(plugin_name, asset_path):
146 - return await _serve_plugin_asset(plugin_name, asset_path)
8 +configure_process_environment()
9
148 -@webapp.route("/extensions/webui/<path:asset_path>", methods=["GET"])
149 -@requires_auth
150 -async def serve_extension_asset(asset_path):
151 - exts = files.get_abs_path("extensions/webui")
152 - path = files.get_abs_path(exts, asset_path)
153 - if not files.is_in_dir(path, exts):
154 - return Response(f"Access denied", 403)
155 - return send_file(path)
10
11 +def run():
12 + PrintStyle().print("Initializing Python framework...")
13 + PrintStyle().print("Checking for data migration...")
14 + run_migration_checks()
15
158 -@extension.extensible
159 -async def _serve_plugin_asset(plugin_name, asset_path):
160 - """
161 - Serve static assets from plugin directories.
162 - Resolves using the plugin system (with overrides).
163 - """
164 - from helpers import plugins
165 -
166 -
167 - # Use the new find_plugin helper
168 - plugin_dir = plugins.find_plugin_dir(plugin_name)
169 - if not plugin_dir:
170 - return Response("Plugin not found", 404)
171 -
172 - # Resolve the plugin asset path with security checks
173 - try:
174 - # Construct path using plugin root
175 - asset_file = files.get_abs_path(plugin_dir, asset_path)
176 - webui_dir = files.get_abs_path(plugin_dir, "webui")
177 - webui_extensions_dir = files.get_abs_path(plugin_dir, "extensions/webui")
178 -
179 - # Security: ensure the resolved path is within the plugin webui directory
180 - if not files.is_in_dir(str(asset_file), str(webui_dir)) and not files.is_in_dir(str(asset_file), str(webui_extensions_dir)):
181 - return Response("Access denied", 403)
182 -
183 - if not files.is_file(asset_file):
184 - return Response("Asset not found", 404)
185 -
186 - return send_file(str(asset_file))
187 - except Exception as e:
188 - PrintStyle.error(f"Error serving plugin asset: {e}")
189 - return Response("Error serving asset", 500)
16 + PrintStyle().print("Preparing web server runtime...")
17 + server_runtime, host, port = prepare_web_runtime()
18
19 + PrintStyle().print("Initializing Agent Zero components...")
20 + init_a0()
21
22 + PrintStyle().print("Starting UI/API server...")
23 + start_web_server(server_runtime, host, port)
24
25
194 -def run():
195 - PrintStyle().print("Initializing framework...")
196 -
197 - # migrate data before anything else
26 +def run_migration_checks() -> None:
27 initialize.initialize_migration()
28
200 - # # Suppress only request logs but keep the startup messages
201 - # from werkzeug.serving import WSGIRequestHandler
202 - # from werkzeug.serving import make_server
203 - # from werkzeug.middleware.dispatcher import DispatcherMiddleware
204 - # from a2wsgi import ASGIMiddleware
205 -
206 - PrintStyle().print("Starting server...")
207 -
208 - # class NoRequestLoggingWSGIRequestHandler(WSGIRequestHandler):
209 - # def log_request(self, code="-", size="-"):
210 - # pass # Override to suppress request logging
29
212 - # Get configuration from environment
213 - port = runtime.get_web_ui_port()
30 +def prepare_web_runtime() -> tuple[UiServerRuntime, str, int]:
31 host = (
32 runtime.get_arg("host") or dotenv.get_dotenv_value("WEB_UI_HOST") or "localhost"
33 )
34 + port = runtime.get_web_ui_port()
35 + server_runtime = UiServerRuntime.create()
36 + server_runtime.register_http_routes()
37 + server_runtime.register_transport_handlers()
38
218 - register_api_route(webapp, lock)
219 -
220 - register_ws_namespace(socketio_server, webapp, lock, manager=ws_manager)
39 + return server_runtime, host, port
40
222 - init_a0()
41
224 - wsgi_app = WSGIMiddleware(webapp)
225 - starlette_app = Starlette(
226 - routes=[
227 - Mount("/mcp", app=mcp_server.DynamicMcpProxy.get_instance()),
228 - Mount("/a2a", app=fasta2a_server.DynamicA2AProxy.get_instance()),
229 - Mount("/", app=wsgi_app),
230 - ]
42 +def start_web_server(server_runtime: UiServerRuntime, host: str, port: int) -> None:
43 + run_uvicorn_with_retries(
44 + host=host,
45 + port=port,
46 + build_asgi_app=server_runtime.build_asgi_app,
47 + flush_callback=create_flush_callback(),
48 + access_log=server_runtime.access_log_enabled(),
49 + ws="wsproto",
50 )
51
233 - asgi_app = ASGIApp(socketio_server, other_asgi_app=starlette_app)
52
53 +def create_flush_callback():
54 def flush_and_shutdown_callback() -> None:
55 """
56 TODO(dev): add cleanup + flush-to-disk logic here.
57 """
58 return
59 +
60 flush_ran = False
61
62 def _run_flush(reason: str) -> None:
@@ -249,62 +69,19 @@ def run():
69 except Exception as e:
70 PrintStyle.warning(f"Shutdown flush failed ({reason}): {e}")
71
252 - config = uvicorn.Config(
253 - asgi_app,
254 - host=host,
255 - port=port,
256 - log_level="info",
257 - access_log=_settings.get("uvicorn_access_logs_enabled", False),
258 - ws="wsproto",
259 - )
260 - server = uvicorn.Server(config)
261 -
262 - class _UvicornServerWrapper:
263 - def __init__(self, server: uvicorn.Server):
264 - self._server = server
265 -
266 - def shutdown(self) -> None:
267 - _run_flush("shutdown")
268 - self._server.should_exit = True
269 -
270 - process.set_server(_UvicornServerWrapper(server))
271 -
272 - PrintStyle().debug(f"Starting server at http://{host}:{port} ...")
273 - threading.Thread(target=wait_for_health, args=(host, port), daemon=True).start()
274 - try:
275 - server.run()
276 - finally:
277 - _run_flush("server_exit")
278 -
279 -
280 -def wait_for_health(host: str, port: int):
281 - url = f"http://{host}:{port}/api/health"
282 - while True:
283 - try:
284 - with urllib.request.urlopen(url, timeout=2) as resp:
285 - if resp.status == 200:
286 - PrintStyle().print("Agent Zero is running.")
287 - return
288 - except Exception:
289 - pass
290 - time.sleep(1)
72 + return _run_flush
73
74
75 @extension.extensible
76 def init_a0():
295 - # initialize contexts and MCP
77 init_chats = initialize.initialize_chats()
297 - # only wait for init chats, otherwise they would seem to disappear for a while on restart
78 init_chats.result_sync()
79
80 initialize.initialize_mcp()
301 - # start job loop
81 initialize.initialize_job_loop()
303 - # preload
82 initialize.initialize_preload()
83
84
307 -# run the internal server
85 if __name__ == "__main__":
86 runtime.initialize()
87 dotenv.load_dotenv()
tests/test_self_update_tag_filter.py
+146 -1
@@ -320,10 +320,15 @@ def test_self_update_update_info_uses_current_branch_for_latest_version(monkeypa
320 "get_selector_tag_options",
321 lambda branch, *, repo_dir=None, current_version=None: (
322 [{"value": "latest", "label": "latest (v1.4)"}],
323 - [],
323 + [2] if branch == "main" else [],
324 "",
325 ),
326 )
327 + monkeypatch.setattr(
328 + self_update,
329 + "get_available_tags",
330 + lambda branch, *, repo_dir=None, query="": (["v1.4", "v1.2"], ""),
331 + )
332 monkeypatch.setattr(
333 self_update,
334 "durable_self_update_supports_latest",
@@ -358,6 +363,102 @@ def test_self_update_update_info_uses_current_branch_for_latest_version(monkeypa
363 "display_version": "v1.4",
364 "commit": "def5678abcd",
365 "short_commit": "def5678",
366 + "released_at": "",
367 + }
368 + assert info["main_branch_latest"] == {
369 + "branch": "main",
370 + "supported": True,
371 + "describe": "v1.4",
372 + "short_tag": "v1.4",
373 + "display_version": "v1.4",
374 + "commit": "def5678abcd",
375 + "short_commit": "def5678",
376 + "released_at": "",
377 + }
378 + assert info["major_upgrade_versions"] == [2]
379 +
380 +
381 +def test_self_update_main_branch_latest_stays_within_current_major(monkeypatch, tmp_path):
382 + monkeypatch.setattr(
383 + self_update,
384 + "get_available_branch_values",
385 + lambda repo_dir=None: ["main", "development"],
386 + )
387 + monkeypatch.setattr(
388 + self_update,
389 + "get_available_tags",
390 + lambda branch, *, repo_dir=None, query="": (
391 + ["v2.0", "v1.4", "v1.2"],
392 + "",
393 + ),
394 + )
395 + monkeypatch.setattr(
396 + self_update,
397 + "_get_branch_head_info",
398 + lambda branch, repo_dir=None: {
399 + "describe": "v2.0",
400 + "short_tag": "v2.0",
401 + "commit": "feedbee1234",
402 + "released_at": "2026-03-30 15:15:50",
403 + },
404 + )
405 + monkeypatch.setattr(
406 + self_update,
407 + "_run_git",
408 + lambda repo_dir, *args: {
409 + ("rev-parse", "refs/tags/v1.4^{commit}"): "deadbeef1234",
410 + }[args],
411 + )
412 + monkeypatch.setattr(
413 + self_update,
414 + "_get_tag_release_time_in_repo",
415 + lambda repo_dir, tag: "2026-02-01 08:30:00" if tag == "v1.4" else "",
416 + )
417 +
418 + info = self_update.get_current_major_main_latest_info("v1.2", repo_dir=tmp_path)
419 +
420 + assert info == {
421 + "branch": "main",
422 + "supported": True,
423 + "describe": "v1.4",
424 + "short_tag": "v1.4",
425 + "display_version": "v1.4",
426 + "commit": "deadbeef1234",
427 + "short_commit": "deadbee",
428 + "released_at": "2026-02-01 08:30:00",
429 + }
430 +
431 +
432 +def test_self_update_remote_branch_head_info_resolves_release_time_before_temp_repo_is_removed(
433 + monkeypatch,
434 +):
435 + monkeypatch.setattr(self_update, "_remote_branch_head_cache", {})
436 +
437 + def fake_run_git(repo_dir, *args):
438 + if args[:2] == ("init", "--bare"):
439 + return ""
440 + if args[0] == "fetch":
441 + return ""
442 + if args[:3] == ("describe", "--tags", "--always"):
443 + return "v1.5"
444 + if args[:2] == ("rev-parse", "refs/remotes/origin/main"):
445 + return "abc1234def5678"
446 + raise AssertionError(args)
447 +
448 + monkeypatch.setattr(self_update, "_run_git", fake_run_git)
449 + monkeypatch.setattr(
450 + self_update,
451 + "_get_tag_release_time_in_repo",
452 + lambda repo_dir, tag: "2026-03-30 15:15:50" if Path(repo_dir).exists() else "",
453 + )
454 +
455 + info = self_update._get_remote_branch_head_info("main")
456 +
457 + assert info == {
458 + "describe": "v1.5",
459 + "short_tag": "v1.5",
460 + "commit": "abc1234def5678",
461 + "released_at": "2026-03-30 15:15:50",
462 }
463
464
@@ -376,9 +477,16 @@ def test_self_update_frontend_uses_preloaded_select():
477 assert "const MIN_SELECTOR_VERSION = [1, 0];" in content
478 assert "availableTagOptions: []" in content
479 assert "higherMajorVersions: []" in content
480 + assert "majorUpgradeVersions: []" in content
481 + assert 'activeTab: "quick"' in content
482 + assert "get hasPendingInitialLoad()" in content
483 + assert "get isCheckingStatus()" in content
484 + assert "get hasMajorUpgrade()" in content
485 + assert "get majorUpgradeBannerMessage()" in content
486 assert "this.applyAvailableTags({" in content
487 assert "response.available_tag_options" in content
488 assert "response.available_higher_major_versions" in content
489 + assert "response.major_upgrade_versions" in content
490 assert "response.tag_options" in content
491 assert "response.higher_major_versions" in content
492 assert "response.pending || {" in content
@@ -390,8 +498,20 @@ def test_self_update_frontend_uses_preloaded_select():
498 assert "Release tag must be v1.0 or newer." in content
499 assert "isLatestSelectorTag(value)" in content
500 assert "this.isSelectableTag(this.form.tag)" in content
501 + assert "get mainBranchLatestTag()" in content
502 + assert "quickUpdateAvailable" in content
503 + assert "scheduleQuickUpdate()" in content
504 + assert 'branch: "main"' in content
505 + assert "quickComparisonIcon" in content
506 + assert "quickComparisonIconClass" in content
507 + assert "Checking update status..." in content
508 + assert '"CHECKING"' in content
509 + assert '"Loading"' in content
510 + assert "formatReleaseTimestamp(value)" in content
511 assert "getLastStatusBadgeClass(status)" in content
512 assert "this.info?.current?.display_version" in content
513 + assert "this.info?.main_branch_latest?.display_version" in content
514 + assert "this.info?.current?.released_at" in content
515 assert "resetRestartState()" in content
516 assert "restartRequestStarted" in content
517 assert "restartResponse.status >= 500" in content
@@ -433,14 +553,39 @@ def test_self_update_modal_uses_standard_select_and_manual_backup():
553 assert "$store.selfUpdateStore.availableTagOptions" in content
554 assert "$store.selfUpdateStore.info?.current?.describe" in content
555 assert "current_branch_latest?.display_version" in content
556 + assert "mainBranchLatestVersion" in content
557 assert "tagOption.label" in content
558 + assert 'id="self-update-quick-tab"' in content
559 + assert 'id="self-update-advanced-tab"' in content
560 assert 'data-bs-target="#self-update-last-attempt-collapse"' in content
561 + assert "New major version available" in content
562 + assert 'x-show="$store.selfUpdateStore.hasMajorUpgrade"' in content
563 + assert "$store.selfUpdateStore.majorUpgradeBannerMessage" in content
564 + assert "Website installation guide" in content
565 assert "self-update-header-status" in content
566 + assert ".status-pill.self-update-quick-status" in content
567 assert "getLastStatusLabel($store.selfUpdateStore.info?.last_status?.status)" in content
568 + assert "Current version vs latest on main" not in content
569 + assert "quickComparisonIcon" in content
570 + assert "quickComparisonIconClass" in content
571 + assert "{ spinning: $store.selfUpdateStore.isCheckingStatus }" in content
572 + assert "formatReleaseTimestamp($store.selfUpdateStore.currentReleasedAt)" in content
573 + assert "formatReleaseTimestamp($store.selfUpdateStore.mainBranchLatestReleasedAt)" in content
574 assert "Latest version" in content
575 assert "Docker update guide" in content
576 assert "https://www.agent-zero.ai/p/docs/get-started/" in content
577 + assert "Version numbers use the format <code>vMAJOR.MINOR</code>" in content
578 + assert "requires a newer" in content
579 + assert "Docker image." in content
580 + assert "minor release line" in content
581 + assert "Agent Zero self-update inside the existing image." in content
582 + assert "On development branches you may also see versions like <code>v1.5+2</code>" in content
583 + assert "This suffix is not used on" in content
584 + assert "Only versions from the current major release line are listed here." in content
585 assert "Manual backup" in content
586 + assert ">Refresh Status<" not in content
587 + assert "Refresh" in content
588 + assert "Loading update status..." not in content
589 assert 'type="button"' in content
590 assert "@blur" not in content
591 assert "selectTag(tag)" not in content
webui/components/settings/external/self-update-modal.html
+576 -280
@@ -14,314 +14,447 @@
14 x-destroy="$store.selfUpdateStore.cleanup()"
15 class="self-update-modal"
16 >
17 - <div class="self-update-version-grid">
18 - <div class="self-update-summary-card">
19 - <div class="summary-label">Current version</div>
20 - <div class="summary-value" x-text="$store.selfUpdateStore.currentVersion"></div>
21 - <div class="summary-meta">
22 - Commit
23 - <code x-text="$store.selfUpdateStore.info?.current?.short_commit || 'unknown'"></code>
17 + <div
18 + class="self-update-warning-banner self-update-major-version-banner"
19 + x-show="$store.selfUpdateStore.hasMajorUpgrade"
20 + >
21 + <div class="self-update-major-version-banner-copy">
22 + <div class="self-update-major-version-banner-title">
23 + New major version available
24 </div>
25 - <div class="summary-meta" x-text="`Branch ${$store.selfUpdateStore.currentBranch || 'unknown'}`"></div>
26 - <template x-if="$store.selfUpdateStore.info?.current?.describe && $store.selfUpdateStore.info?.current?.describe !== $store.selfUpdateStore.info?.current?.short_tag">
27 - <div
28 - class="summary-meta"
29 - x-text="$store.selfUpdateStore.info?.current?.describe"
30 - ></div>
31 - </template>
25 + <div x-text="$store.selfUpdateStore.majorUpgradeBannerMessage"></div>
26 </div>
27 + <a
28 + href="https://www.agent-zero.ai/p/docs/get-started/"
29 + target="_blank"
30 + rel="noreferrer"
31 + >
32 + Website installation guide
33 + </a>
34 + </div>
35
34 - <div class="self-update-summary-card">
35 - <div class="summary-label">Latest version</div>
36 - <div
37 - class="summary-value"
38 - x-text="$store.selfUpdateStore.info?.current_branch_latest?.display_version || 'Unavailable'"
39 - ></div>
40 - <template x-if="$store.selfUpdateStore.info?.current_branch_latest?.short_commit && $store.selfUpdateStore.info?.current_branch_latest?.branch !== 'main'">
41 - <div class="summary-meta">
42 - Commit
43 - <code x-text="$store.selfUpdateStore.info?.current_branch_latest?.short_commit"></code>
36 + <div class="self-update-tabs">
37 + <ul class="nav nav-tabs" id="self-update-tabs" role="tablist">
38 + <li class="nav-item" role="presentation">
39 + <button
40 + class="nav-link"
41 + :class="{ active: $store.selfUpdateStore.activeTab === 'quick' }"
42 + id="self-update-quick-tab"
43 + type="button"
44 + role="tab"
45 + aria-controls="self-update-quick"
46 + :aria-selected="$store.selfUpdateStore.activeTab === 'quick'"
47 + @click="$store.selfUpdateStore.setTab('quick')"
48 + >
49 + Quick
50 + </button>
51 + </li>
52 + <li class="nav-item" role="presentation">
53 + <button
54 + class="nav-link"
55 + :class="{ active: $store.selfUpdateStore.activeTab === 'advanced' }"
56 + id="self-update-advanced-tab"
57 + type="button"
58 + role="tab"
59 + aria-controls="self-update-advanced"
60 + :aria-selected="$store.selfUpdateStore.activeTab === 'advanced'"
61 + @click="$store.selfUpdateStore.setTab('advanced')"
62 + >
63 + Advanced
64 + </button>
65 + </li>
66 + </ul>
67 + </div>
68 +
69 + <template x-if="$store.selfUpdateStore.activeTab === 'quick'">
70 + <div id="self-update-quick" role="tabpanel" aria-labelledby="self-update-quick-tab">
71 + <div class="self-update-quick-card">
72 + <div class="self-update-quick-header">
73 + <div
74 + class="status-pill self-update-quick-status"
75 + :class="$store.selfUpdateStore.quickStatusBadgeClass"
76 + x-text="$store.selfUpdateStore.quickStatusLabel"
77 + ></div>
78 </div>
45 - </template>
46 - <div
47 - class="summary-meta"
48 - x-text="`Branch ${$store.selfUpdateStore.info?.current_branch_latest?.branch || $store.selfUpdateStore.currentBranch || 'unknown'}`"
49 - ></div>
50 - <template x-if="$store.selfUpdateStore.info?.current_branch_latest?.describe && $store.selfUpdateStore.info?.current_branch_latest?.describe !== $store.selfUpdateStore.info?.current_branch_latest?.short_tag">
79 +
80 <div
52 - class="summary-meta"
53 - x-text="$store.selfUpdateStore.info?.current_branch_latest?.describe"
81 + class="self-update-quick-copy self-update-quick-copy-prominent"
82 + x-text="$store.selfUpdateStore.quickStatusMessage"
83 ></div>
55 - </template>
56 - <template x-if="$store.selfUpdateStore.info?.current_branch_latest?.supported === false">
57 - <div class="summary-meta">
58 - Latest official version is only tracked for <code>main</code>, <code>testing</code>,
59 - and <code>development</code>.
84 +
85 + <div class="self-update-quick-grid">
86 + <div class="self-update-quick-version-card">
87 + <div class="summary-label">Current</div>
88 + <div class="summary-value" x-text="$store.selfUpdateStore.currentVersion"></div>
89 + <div class="summary-meta">
90 + <span x-text="$store.selfUpdateStore.formatReleaseTimestamp($store.selfUpdateStore.currentReleasedAt)"></span>
91 + </div>
92 + </div>
93 +
94 + <div class="self-update-quick-arrow" :class="$store.selfUpdateStore.quickComparisonIconClass" aria-hidden="true">
95 + <span
96 + class="material-symbols-outlined"
97 + :class="{ spinning: $store.selfUpdateStore.isCheckingStatus }"
98 + x-text="$store.selfUpdateStore.quickComparisonIcon"
99 + ></span>
100 + </div>
101 +
102 + <div class="self-update-quick-version-card self-update-quick-version-card-accent">
103 + <div class="summary-label">Latest on main</div>
104 + <div
105 + class="summary-value"
106 + x-text="$store.selfUpdateStore.mainBranchLatestVersion"
107 + ></div>
108 + <div class="summary-meta">
109 + <span x-text="$store.selfUpdateStore.formatReleaseTimestamp($store.selfUpdateStore.mainBranchLatestReleasedAt)"></span>
110 + </div>
111 + </div>
112 </div>
61 - </template>
62 - </div>
63 - </div>
113
65 - <div class="self-update-summary-grid" x-show="$store.selfUpdateStore.info?.pending">
66 - <div class="self-update-summary-card">
67 - <div class="summary-label">Pending request</div>
68 - <div
69 - class="summary-value"
70 - x-text="$store.selfUpdateStore.formatBranchTag($store.selfUpdateStore.info?.pending?.branch, $store.selfUpdateStore.info?.pending?.tag)"
71 - ></div>
72 - <div
73 - class="summary-meta"
74 - x-text="$store.selfUpdateStore.formatTimestamp($store.selfUpdateStore.info?.pending?.requested_at)"
75 - ></div>
76 - </div>
77 - </div>
114 + <div class="self-update-quick-note">
115 + Quick mode always targets <code>main</code> and uses the default backup settings.
116 + Use Advanced for a different branch, manual version selection, or custom backup behavior.
117 + </div>
118 + </div>
119
79 - <template x-if="!$store.selfUpdateStore.isSupported">
80 - <div class="self-update-warning">
81 - Self-update is currently available only in dockerized Agent Zero deployments
82 - that boot through <code>/exe/run_A0.sh</code>.
120 + <div
121 + class="self-update-warning-banner"
122 + x-show="$store.selfUpdateStore.quickMajorUpgradeNotice"
123 + >
124 + <span x-text="$store.selfUpdateStore.quickMajorUpgradeNotice"></span>
125 + </div>
126 </div>
127 </template>
128
86 - <div class="self-update-panel">
87 - <button
88 - type="button"
89 - class="self-update-panel-toggle"
90 - data-bs-toggle="collapse"
91 - data-bs-target="#self-update-howto-collapse"
92 - aria-expanded="false"
93 - aria-controls="self-update-howto-collapse"
129 + <template x-if="$store.selfUpdateStore.activeTab === 'advanced'">
130 + <div
131 + id="self-update-advanced"
132 + role="tabpanel"
133 + aria-labelledby="self-update-advanced-tab"
134 + class="self-update-advanced-layout"
135 >
95 - <span>How it works?</span>
96 - <span class="material-symbols-outlined self-update-panel-toggle-icon">expand_more</span>
97 - </button>
98 - <div class="collapse" id="self-update-howto-collapse">
99 - <div class="self-update-panel-body self-update-copy">
100 - <p>
101 - Agent Zero saves this request into
102 - <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>,
103 - restarts once, applies the requested branch and version target before the UI
104 - starts again, then reloads this page when <code>/api/health</code> is healthy.
105 - </p>
106 - <p>
107 - If the updated UI does not become healthy within 2 minutes, the bootstrap
108 - manager in <code>/exe</code> restores the previous checkout and starts that
109 - version again, so even an older downgraded <code>/a0</code> can be upgraded back
110 - by creating the YAML file manually.
111 - </p>
112 - </div>
113 - </div>
114 - </div>
115 -
116 - <template x-if="$store.selfUpdateStore.info?.last_status">
117 - <div class="self-update-panel">
118 - <button
119 - type="button"
120 - class="self-update-panel-toggle"
121 - data-bs-toggle="collapse"
122 - data-bs-target="#self-update-last-attempt-collapse"
123 - aria-expanded="false"
124 - aria-controls="self-update-last-attempt-collapse"
125 - >
126 - <span>Last Attempt</span>
127 - <span class="self-update-panel-toggle-trailing">
128 - <span
129 - class="status-pill self-update-header-status"
130 - :class="$store.selfUpdateStore.getLastStatusBadgeClass($store.selfUpdateStore.info?.last_status?.status)"
131 - x-text="$store.selfUpdateStore.getLastStatusLabel($store.selfUpdateStore.info?.last_status?.status)"
132 - ></span>
133 - <span class="material-symbols-outlined self-update-panel-toggle-icon">expand_more</span>
134 - </span>
135 - </button>
136 - <div class="collapse" id="self-update-last-attempt-collapse">
137 - <div class="self-update-panel-body">
138 - <div class="status-message" x-text="$store.selfUpdateStore.info?.last_status?.message || ''"></div>
136 + <div class="self-update-version-grid">
137 + <div class="self-update-summary-card">
138 + <div class="summary-label">Current version</div>
139 + <div class="summary-value" x-text="$store.selfUpdateStore.currentVersion"></div>
140 <div class="summary-meta">
140 - Trigger:
141 - <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>
142 - </div>
143 - <div class="summary-meta">
144 - Log:
145 - <code x-text="$store.selfUpdateStore.info?.paths?.log_file || '/exe/a0-self-update.log'"></code>
141 + Commit
142 + <code x-text="$store.selfUpdateStore.info?.current?.short_commit || 'unknown'"></code>
143 </div>
144 + <div class="summary-meta" x-text="`Branch ${$store.selfUpdateStore.currentBranch || 'unknown'}`"></div>
145 + <template x-if="$store.selfUpdateStore.info?.current?.describe && $store.selfUpdateStore.info?.current?.describe !== $store.selfUpdateStore.info?.current?.short_tag">
146 + <div
147 + class="summary-meta"
148 + x-text="$store.selfUpdateStore.info?.current?.describe"
149 + ></div>
150 + </template>
151 + </div>
152 +
153 + <div class="self-update-summary-card">
154 + <div class="summary-label">Latest version</div>
155 + <div
156 + class="summary-value"
157 + x-text="$store.selfUpdateStore.info?.current_branch_latest?.display_version || 'Unavailable'"
158 + ></div>
159 + <template x-if="$store.selfUpdateStore.info?.current_branch_latest?.short_commit && $store.selfUpdateStore.info?.current_branch_latest?.branch !== 'main'">
160 + <div class="summary-meta">
161 + Commit
162 + <code x-text="$store.selfUpdateStore.info?.current_branch_latest?.short_commit"></code>
163 + </div>
164 + </template>
165 <div
166 class="summary-meta"
149 - x-text="$store.selfUpdateStore.formatTimestamp($store.selfUpdateStore.info?.last_status?.finished_at)"
167 + x-text="`Branch ${$store.selfUpdateStore.info?.current_branch_latest?.branch || $store.selfUpdateStore.currentBranch || 'unknown'}`"
168 ></div>
151 - <template x-if="$store.selfUpdateStore.info?.last_status?.backup_zip_path">
152 - <div class="status-path">
153 - Backup:
154 - <code x-text="$store.selfUpdateStore.info?.last_status?.backup_zip_path"></code>
169 + <template x-if="$store.selfUpdateStore.info?.current_branch_latest?.describe && $store.selfUpdateStore.info?.current_branch_latest?.describe !== $store.selfUpdateStore.info?.current_branch_latest?.short_tag">
170 + <div
171 + class="summary-meta"
172 + x-text="$store.selfUpdateStore.info?.current_branch_latest?.describe"
173 + ></div>
174 + </template>
175 + <template x-if="$store.selfUpdateStore.info?.current_branch_latest?.supported === false">
176 + <div class="summary-meta">
177 + Latest official version is only tracked for <code>main</code>, <code>testing</code>,
178 + and <code>development</code>.
179 </div>
180 </template>
181 </div>
182 </div>
159 - </div>
160 - </template>
183
162 - <template x-if="$store.selfUpdateStore.isSupported">
163 - <div>
164 - <div class="field">
165 - <div class="field-label">
166 - <div class="field-title">Target branch</div>
167 - <div class="field-description">
168 - Choose which official branch context should be used when resolving the requested tag.
169 - </div>
170 - </div>
171 - <div class="field-control">
172 - <select
173 - x-model="$store.selfUpdateStore.form.branch"
174 - x-effect="$nextTick(() => { $el.value = $store.selfUpdateStore.form.branch || 'main'; })"
175 - @change="$store.selfUpdateStore.onBranchChanged()"
176 - :disabled="$store.selfUpdateStore.isBusy"
177 - >
178 - <template x-for="branch in ($store.selfUpdateStore.info?.branches || [])" :key="branch.value">
179 - <option :value="branch.value" x-text="branch.label"></option>
180 - </template>
181 - </select>
184 + <div class="self-update-summary-grid" x-show="$store.selfUpdateStore.info?.pending">
185 + <div class="self-update-summary-card">
186 + <div class="summary-label">Pending request</div>
187 + <div
188 + class="summary-value"
189 + x-text="$store.selfUpdateStore.formatBranchTag($store.selfUpdateStore.info?.pending?.branch, $store.selfUpdateStore.info?.pending?.tag)"
190 + ></div>
191 + <div
192 + class="summary-meta"
193 + x-text="$store.selfUpdateStore.formatTimestamp($store.selfUpdateStore.info?.pending?.requested_at)"
194 + ></div>
195 </div>
196 </div>
197
185 - <div class="field">
186 - <div class="field-label">
187 - <div class="field-title">Version</div>
188 - <div class="field-description">
189 - Choose a preloaded version target from the
190 - <a href="https://github.com/agent0ai/agent-zero" target="_blank" rel="noreferrer">Agent Zero repository</a>.
191 - Only versions from the current major release line are listed here. Newer major lines require a Docker image update first.
192 - </div>
193 - <div class="field-description">
194 - <code>latest</code> resolves to the newest tag on <code>main</code>, and to the current branch head on <code>testing</code> and <code>development</code>.
195 - </div>
196 - <template x-if="$store.selfUpdateStore.tagsError">
197 - <div class="field-description">
198 - Version lookup failed:
199 - <span x-text="$store.selfUpdateStore.tagsError"></span>
200 - </div>
201 - </template>
198 + <template x-if="!$store.selfUpdateStore.isSupported">
199 + <div class="self-update-warning">
200 + Self-update is currently available only in dockerized Agent Zero deployments
201 + that boot through <code>/exe/run_A0.sh</code>.
202 </div>
203 - <template x-if="$store.selfUpdateStore.higherMajorVersionMessage">
204 - <div class="self-update-warning-banner">
205 - <div x-text="$store.selfUpdateStore.higherMajorVersionMessage"></div>
206 - <a
207 - href="https://www.agent-zero.ai/p/docs/get-started/"
208 - target="_blank"
209 - rel="noreferrer"
210 - >
211 - Docker update guide
212 - </a>
213 - </div>
214 - </template>
215 - <div class="field-control">
216 - <select
217 - x-model="$store.selfUpdateStore.form.tag"
218 - :disabled="$store.selfUpdateStore.isBusy || $store.selfUpdateStore.tagsLoading || !$store.selfUpdateStore.hasAvailableTags"
219 - >
220 - <option value="" x-text="$store.selfUpdateStore.versionSelectPlaceholder"></option>
221 - <template x-for="tagOption in $store.selfUpdateStore.availableTagOptions" :key="tagOption.value">
222 - <option :value="tagOption.value" x-text="tagOption.label"></option>
223 - </template>
224 - </select>
225 - </div>
226 - </div>
203 + </template>
204
228 - <div class="field">
229 - <div class="field-label">
230 - <div class="field-title">Back up <code>/a0/usr</code> first</div>
231 - <div class="field-description">
232 - Creates a zip backup before the release files are replaced.
205 + <div class="self-update-panel">
206 + <button
207 + type="button"
208 + class="self-update-panel-toggle"
209 + data-bs-toggle="collapse"
210 + data-bs-target="#self-update-howto-collapse"
211 + aria-expanded="false"
212 + aria-controls="self-update-howto-collapse"
213 + >
214 + <span>How it works?</span>
215 + <span class="material-symbols-outlined self-update-panel-toggle-icon">expand_more</span>
216 + </button>
217 + <div class="collapse" id="self-update-howto-collapse">
218 + <div class="self-update-panel-body self-update-copy">
219 + <p>
220 + Agent Zero saves this request into
221 + <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>,
222 + restarts once, applies the requested branch and version target before the UI
223 + starts again, then reloads this page when <code>/api/health</code> is healthy.
224 + </p>
225 + <p>
226 + If the updated UI does not become healthy within 2 minutes, the bootstrap
227 + manager in <code>/exe</code> restores the previous checkout and starts that
228 + version again, so even an older downgraded <code>/a0</code> can be upgraded back
229 + by creating the YAML file manually.
230 + </p>
231 + <p>
232 + Version numbers use the format <code>vMAJOR.MINOR</code>. The first number is
233 + the major release line and moving to a newer major version requires a newer
234 + Docker image. The second number is the minor release line and those updates can
235 + be applied through Agent Zero self-update inside the existing image.
236 + </p>
237 + <p>
238 + On development branches you may also see versions like <code>v1.5+2</code>.
239 + The <code>+2</code> means that checkout is based on <code>v1.5</code> plus two
240 + additional commits after that release tag. This suffix is not used on
241 + <code>main</code>.
242 + </p>
243 </div>
244 </div>
235 - <div class="field-control">
236 - <label class="toggle">
237 - <input
238 - type="checkbox"
239 - x-model="$store.selfUpdateStore.form.backup_usr"
240 - :disabled="$store.selfUpdateStore.isBusy"
241 - />
242 - <span class="toggler"></span>
243 - </label>
244 - </div>
245 </div>
246
247 - <div x-show="$store.selfUpdateStore.form.backup_usr">
248 - <div class="field">
249 - <div class="field-label">
250 - <div class="field-title">Backup directory</div>
251 - <div class="field-description">
252 - Absolute or repo-relative path where the <code>usr</code> zip should be written.
247 + <template x-if="$store.selfUpdateStore.info?.last_status">
248 + <div class="self-update-panel">
249 + <button
250 + type="button"
251 + class="self-update-panel-toggle"
252 + data-bs-toggle="collapse"
253 + data-bs-target="#self-update-last-attempt-collapse"
254 + aria-expanded="false"
255 + aria-controls="self-update-last-attempt-collapse"
256 + >
257 + <span>Last Attempt</span>
258 + <span class="self-update-panel-toggle-trailing">
259 + <span
260 + class="status-pill self-update-header-status"
261 + :class="$store.selfUpdateStore.getLastStatusBadgeClass($store.selfUpdateStore.info?.last_status?.status)"
262 + x-text="$store.selfUpdateStore.getLastStatusLabel($store.selfUpdateStore.info?.last_status?.status)"
263 + ></span>
264 + <span class="material-symbols-outlined self-update-panel-toggle-icon">expand_more</span>
265 + </span>
266 + </button>
267 + <div class="collapse" id="self-update-last-attempt-collapse">
268 + <div class="self-update-panel-body">
269 + <div class="status-message" x-text="$store.selfUpdateStore.info?.last_status?.message || ''"></div>
270 + <div class="summary-meta">
271 + Trigger:
272 + <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>
273 + </div>
274 + <div class="summary-meta">
275 + Log:
276 + <code x-text="$store.selfUpdateStore.info?.paths?.log_file || '/exe/a0-self-update.log'"></code>
277 + </div>
278 + <div
279 + class="summary-meta"
280 + x-text="$store.selfUpdateStore.formatTimestamp($store.selfUpdateStore.info?.last_status?.finished_at)"
281 + ></div>
282 + <template x-if="$store.selfUpdateStore.info?.last_status?.backup_zip_path">
283 + <div class="status-path">
284 + Backup:
285 + <code x-text="$store.selfUpdateStore.info?.last_status?.backup_zip_path"></code>
286 + </div>
287 + </template>
288 </div>
289 </div>
255 - <div class="field-control">
256 - <input
257 - type="text"
258 - x-model="$store.selfUpdateStore.form.backup_path"
259 - :disabled="$store.selfUpdateStore.isBusy"
260 - />
261 - </div>
290 </div>
291 + </template>
292
264 - <div class="field">
265 - <div class="field-label">
266 - <div class="field-title">Manual backup</div>
267 - <div class="field-description">
268 - Open the existing backup and restore modal if you want to create a backup before scheduling the update.
293 + <template x-if="$store.selfUpdateStore.isSupported">
294 + <div>
295 + <div class="field">
296 + <div class="field-label">
297 + <div class="field-title">Target branch</div>
298 + <div class="field-description">
299 + Choose which official branch context should be used when resolving the requested tag.
300 + </div>
301 + </div>
302 + <div class="field-control">
303 + <select
304 + x-model="$store.selfUpdateStore.form.branch"
305 + x-effect="$nextTick(() => { $el.value = $store.selfUpdateStore.form.branch || 'main'; })"
306 + @change="$store.selfUpdateStore.onBranchChanged()"
307 + :disabled="$store.selfUpdateStore.isBusy"
308 + >
309 + <template x-for="branch in ($store.selfUpdateStore.info?.branches || [])" :key="branch.value">
310 + <option :value="branch.value" x-text="branch.label"></option>
311 + </template>
312 + </select>
313 </div>
314 </div>
271 - <div class="field-control">
272 - <button
273 - type="button"
274 - class="btn btn-field"
275 - @click="$store.selfUpdateStore.openManualBackupModal()"
276 - :disabled="$store.selfUpdateStore.isBusy"
277 - >
278 - Manual backup
279 - </button>
280 - </div>
281 - </div>
315
283 - <div class="field">
284 - <div class="field-label">
285 - <div class="field-title">Backup filename</div>
286 - <div class="field-description">
287 - The manager normalizes this into a safe <code>.zip</code> filename. Leave it as-is for the default <code>usr-timestamp.zip</code> format.
316 + <div class="field">
317 + <div class="field-label">
318 + <div class="field-title">Version</div>
319 + <div class="field-description">
320 + Choose a preloaded version target from the
321 + <a href="https://github.com/agent0ai/agent-zero" target="_blank" rel="noreferrer">Agent Zero repository</a>.
322 + Only versions from the current major release line are listed here. Newer major lines require a Docker image update first.
323 + </div>
324 + <div class="field-description">
325 + <code>latest</code> resolves to the newest tag on <code>main</code>, and to the current branch head on <code>testing</code> and <code>development</code>.
326 + </div>
327 + <template x-if="$store.selfUpdateStore.tagsError">
328 + <div class="field-description">
329 + Version lookup failed:
330 + <span x-text="$store.selfUpdateStore.tagsError"></span>
331 + </div>
332 + </template>
333 + </div>
334 + <template x-if="$store.selfUpdateStore.higherMajorVersionMessage">
335 + <div class="self-update-warning-banner">
336 + <div x-text="$store.selfUpdateStore.higherMajorVersionMessage"></div>
337 + <a
338 + href="https://www.agent-zero.ai/p/docs/get-started/"
339 + target="_blank"
340 + rel="noreferrer"
341 + >
342 + Docker update guide
343 + </a>
344 + </div>
345 + </template>
346 + <div class="field-control">
347 + <select
348 + x-model="$store.selfUpdateStore.form.tag"
349 + :disabled="$store.selfUpdateStore.isBusy || $store.selfUpdateStore.tagsLoading || !$store.selfUpdateStore.hasAvailableTags"
350 + >
351 + <option value="" x-text="$store.selfUpdateStore.versionSelectPlaceholder"></option>
352 + <template x-for="tagOption in $store.selfUpdateStore.availableTagOptions" :key="tagOption.value">
353 + <option :value="tagOption.value" x-text="tagOption.label"></option>
354 + </template>
355 + </select>
356 </div>
357 </div>
290 - <div class="field-control">
291 - <input
292 - type="text"
293 - x-model="$store.selfUpdateStore.form.backup_name"
294 - :disabled="$store.selfUpdateStore.isBusy"
295 - />
358 +
359 + <div class="field">
360 + <div class="field-label">
361 + <div class="field-title">Back up <code>/a0/usr</code> first</div>
362 + <div class="field-description">
363 + Creates a zip backup before the release files are replaced.
364 + </div>
365 + </div>
366 + <div class="field-control">
367 + <label class="toggle">
368 + <input
369 + type="checkbox"
370 + x-model="$store.selfUpdateStore.form.backup_usr"
371 + :disabled="$store.selfUpdateStore.isBusy"
372 + />
373 + <span class="toggler"></span>
374 + </label>
375 + </div>
376 </div>
297 - </div>
377
299 - <div class="field">
300 - <div class="field-label">
301 - <div class="field-title">If the filename already exists</div>
302 - <div class="field-description">
303 - Choose whether to rename the backup, replace it, or stop the update.
378 + <div x-show="$store.selfUpdateStore.form.backup_usr">
379 + <div class="field">
380 + <div class="field-label">
381 + <div class="field-title">Backup directory</div>
382 + <div class="field-description">
383 + Absolute or repo-relative path where the <code>usr</code> zip should be written.
384 + </div>
385 + </div>
386 + <div class="field-control">
387 + <input
388 + type="text"
389 + x-model="$store.selfUpdateStore.form.backup_path"
390 + :disabled="$store.selfUpdateStore.isBusy"
391 + />
392 + </div>
393 + </div>
394 +
395 + <div class="field">
396 + <div class="field-label">
397 + <div class="field-title">Manual backup</div>
398 + <div class="field-description">
399 + Open the existing backup and restore modal if you want to create a backup before scheduling the update.
400 + </div>
401 + </div>
402 + <div class="field-control">
403 + <button
404 + type="button"
405 + class="btn btn-field"
406 + @click="$store.selfUpdateStore.openManualBackupModal()"
407 + :disabled="$store.selfUpdateStore.isBusy"
408 + >
409 + Manual backup
410 + </button>
411 + </div>
412 + </div>
413 +
414 + <div class="field">
415 + <div class="field-label">
416 + <div class="field-title">Backup filename</div>
417 + <div class="field-description">
418 + The manager normalizes this into a safe <code>.zip</code> filename. Leave it as-is for the default <code>usr-timestamp.zip</code> format.
419 + </div>
420 + </div>
421 + <div class="field-control">
422 + <input
423 + type="text"
424 + x-model="$store.selfUpdateStore.form.backup_name"
425 + :disabled="$store.selfUpdateStore.isBusy"
426 + />
427 + </div>
428 + </div>
429 +
430 + <div class="field">
431 + <div class="field-label">
432 + <div class="field-title">If the filename already exists</div>
433 + <div class="field-description">
434 + Choose whether to rename the backup, replace it, or stop the update.
435 + </div>
436 + </div>
437 + <div class="field-control">
438 + <select
439 + x-model="$store.selfUpdateStore.form.backup_conflict_policy"
440 + :disabled="$store.selfUpdateStore.isBusy"
441 + >
442 + <option value="rename">Rename with suffix</option>
443 + <option value="overwrite">Overwrite existing zip</option>
444 + <option value="fail">Fail before restart</option>
445 + </select>
446 + </div>
447 </div>
448 </div>
306 - <div class="field-control">
307 - <select
308 - x-model="$store.selfUpdateStore.form.backup_conflict_policy"
309 - :disabled="$store.selfUpdateStore.isBusy"
310 - >
311 - <option value="rename">Rename with suffix</option>
312 - <option value="overwrite">Overwrite existing zip</option>
313 - <option value="fail">Fail before restart</option>
314 - </select>
449 +
450 + <div class="self-update-file-hint">
451 + The durable trigger, status, and log files live outside <code>/a0</code>:
452 + <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>,
453 + <code x-text="$store.selfUpdateStore.info?.paths?.status_file || '/exe/a0-self-update-status.yaml'"></code>,
454 + <code x-text="$store.selfUpdateStore.info?.paths?.log_file || '/exe/a0-self-update.log'"></code>.
455 </div>
456 </div>
317 - </div>
318 -
319 - <div class="self-update-file-hint">
320 - The durable trigger, status, and log files live outside <code>/a0</code>:
321 - <code x-text="$store.selfUpdateStore.info?.paths?.update_file || '/exe/a0-self-update.yaml'"></code>,
322 - <code x-text="$store.selfUpdateStore.info?.paths?.status_file || '/exe/a0-self-update-status.yaml'"></code>,
323 - <code x-text="$store.selfUpdateStore.info?.paths?.log_file || '/exe/a0-self-update.log'"></code>.
324 - </div>
457 + </template>
458 </div>
459 </template>
460
@@ -329,9 +462,6 @@
462 <span x-text="$store.selfUpdateStore.error"></span>
463 </div>
464
332 - <div class="self-update-loading" x-show="$store.selfUpdateStore.loading">
333 - Loading update status...
334 - </div>
465 <div class="self-update-progress-state" x-show="$store.selfUpdateStore.restarting">
466 <div class="self-update-progress-spinner"></div>
467 <div>
@@ -341,21 +471,33 @@
471 </div>
472
473 <div class="modal-footer" data-modal-footer>
344 - <button
345 - type="button"
346 - class="btn btn-ok"
347 - @click="$store.selfUpdateStore.scheduleUpdate()"
348 - :disabled="!$store.selfUpdateStore.canScheduleUpdate"
349 - >
350 - Restart and Update
351 - </button>
474 + <template x-if="$store.selfUpdateStore.activeTab === 'quick'">
475 + <button
476 + type="button"
477 + class="btn btn-ok"
478 + @click="$store.selfUpdateStore.scheduleQuickUpdate()"
479 + :disabled="!$store.selfUpdateStore.quickUpdateAvailable"
480 + >
481 + Restart and Update
482 + </button>
483 + </template>
484 + <template x-if="$store.selfUpdateStore.activeTab === 'advanced'">
485 + <button
486 + type="button"
487 + class="btn btn-ok"
488 + @click="$store.selfUpdateStore.scheduleUpdate()"
489 + :disabled="!$store.selfUpdateStore.canScheduleUpdate"
490 + >
491 + Restart and Update
492 + </button>
493 + </template>
494 <button
495 type="button"
496 class="btn btn-field"
497 @click="$store.selfUpdateStore.refresh()"
498 :disabled="$store.selfUpdateStore.isBusy"
499 >
358 - Refresh Status
500 + Refresh
501 </button>
502 <button
503 type="button"
@@ -377,6 +519,146 @@
519 gap: 1rem;
520 }
521
522 + .self-update-tabs .nav {
523 + display: flex;
524 + padding-left: 0;
525 + margin: 0;
526 + list-style: none;
527 + border-bottom: 1px solid var(--color-border);
528 + gap: 0.25rem;
529 + }
530 +
531 + .self-update-major-version-banner {
532 + margin-bottom: -0.1rem;
533 + }
534 +
535 + .self-update-major-version-banner-copy {
536 + display: flex;
537 + flex-direction: column;
538 + gap: 0.2rem;
539 + max-width: 58rem;
540 + }
541 +
542 + .self-update-major-version-banner-title {
543 + font-weight: 700;
544 + }
545 +
546 + .self-update-tabs .nav-link {
547 + font-family: "Rubik", Arial, Helvetica, sans-serif;
548 + border: 1px solid transparent;
549 + border-top-left-radius: 4px;
550 + border-top-right-radius: 4px;
551 + padding: 0.4rem 0.7rem;
552 + background: transparent;
553 + color: var(--color-text-secondary);
554 + cursor: pointer;
555 + display: inline-flex;
556 + align-items: center;
557 + gap: 0.3rem;
558 + }
559 +
560 + .self-update-tabs .nav-link.active {
561 + color: var(--color-text-primary);
562 + border-color: var(--color-border);
563 + border-bottom-color: transparent;
564 + background: var(--color-background);
565 + }
566 +
567 + .self-update-quick-card {
568 + padding: 0.25rem 0 0;
569 + }
570 +
571 + .self-update-quick-header {
572 + display: flex;
573 + align-items: center;
574 + justify-content: flex-start;
575 + gap: 0.75rem;
576 + margin-bottom: 0.65rem;
577 + }
578 +
579 + .status-pill.self-update-quick-status {
580 + margin-top: 0;
581 + padding: 0.34rem 0.82rem;
582 + font-size: 0.98rem;
583 + font-weight: 700;
584 + letter-spacing: 0.05em;
585 + }
586 +
587 + .self-update-quick-copy {
588 + color: var(--color-text-muted);
589 + line-height: 1.55;
590 + }
591 +
592 + .self-update-quick-copy-prominent {
593 + font-size: 1.18rem;
594 + color: var(--color-text);
595 + max-width: 58rem;
596 + }
597 +
598 + .self-update-quick-grid {
599 + display: grid;
600 + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
601 + gap: 0.9rem;
602 + align-items: stretch;
603 + margin-top: 1rem;
604 + }
605 +
606 + .self-update-quick-version-card {
607 + border: 1px solid var(--color-border);
608 + border-radius: 1rem;
609 + padding: 1rem;
610 + background: var(--color-panel);
611 + }
612 +
613 + .self-update-quick-version-card-accent {
614 + border-color: color-mix(in srgb, var(--color-border) 85%, transparent);
615 + }
616 +
617 + .self-update-quick-arrow {
618 + display: flex;
619 + align-items: center;
620 + justify-content: center;
621 + min-width: 3.25rem;
622 + }
623 +
624 + .self-update-quick-arrow .material-symbols-outlined {
625 + font-size: 2rem;
626 + }
627 +
628 + .self-update-quick-icon-success {
629 + color: var(--color-success, #16a34a);
630 + }
631 +
632 + .self-update-quick-icon-info {
633 + color: var(--color-primary, #2563eb);
634 + }
635 +
636 + .self-update-quick-icon-warning {
637 + color: var(--color-warning, #d97706);
638 + }
639 +
640 + .self-update-quick-icon-neutral {
641 + color: var(--color-text-muted);
642 + }
643 +
644 + .self-update-quick-version-card .summary-value {
645 + font-size: clamp(2rem, 3vw, 2.5rem);
646 + line-height: 1.05;
647 + }
648 +
649 + .self-update-quick-version-card .summary-meta {
650 + margin-top: 0.65rem;
651 + font-size: 1rem;
652 + }
653 +
654 + .self-update-quick-note {
655 + margin-top: 1rem;
656 + padding-top: 1rem;
657 + border-top: 1px solid color-mix(in srgb, var(--color-border) 78%, transparent);
658 + color: var(--color-text-muted);
659 + line-height: 1.5;
660 + }
661 +
662 .self-update-copy {
663 color: var(--color-text-muted);
664 line-height: 1.5;
@@ -386,6 +668,12 @@
668 margin: 0 0 0.75rem;
669 }
670
671 + .self-update-advanced-layout {
672 + display: flex;
673 + flex-direction: column;
674 + gap: 1rem;
675 + }
676 +
677 .self-update-panel {
678 border: 1px solid var(--color-border);
679 border-radius: 0.9rem;
@@ -517,8 +805,7 @@
805 }
806
807 .self-update-warning,
520 - .self-update-error,
521 - .self-update-loading {
808 + .self-update-error {
809 padding: 0.8rem 0.9rem;
810 border-radius: 8px;
811 }
@@ -533,11 +820,6 @@
820 color: var(--color-error);
821 }
822
536 - .self-update-loading {
537 - background: var(--color-panel);
538 - color: var(--color-text-muted);
539 - }
540 -
823 .self-update-file-hint {
824 color: var(--color-text-muted);
825 line-height: 1.5;
@@ -566,6 +848,12 @@
848 color: var(--color-success, #16a34a);
849 }
850
851 + .status-pill-info {
852 + border-color: color-mix(in srgb, var(--color-primary, #2563eb) 55%, var(--color-border));
853 + background: color-mix(in srgb, var(--color-primary, #2563eb) 16%, transparent);
854 + color: var(--color-primary, #2563eb);
855 + }
856 +
857 .status-pill-error {
858 border-color: color-mix(in srgb, var(--color-error, #dc2626) 55%, var(--color-border));
859 background: color-mix(in srgb, var(--color-error, #dc2626) 18%, transparent);
@@ -590,6 +878,14 @@
878 }
879
880 @media (max-width: 720px) {
881 + .self-update-quick-grid {
882 + grid-template-columns: minmax(0, 1fr);
883 + }
884 +
885 + .self-update-quick-arrow {
886 + transform: rotate(90deg);
887 + }
888 +
889 .self-update-version-grid {
890 grid-template-columns: minmax(0, 1fr);
891 }
webui/components/settings/external/self-update-store.js
+311 -35
@@ -15,11 +15,13 @@ const model = {
15 saving: false,
16 restarting: false,
17 tagsLoading: false,
18 + activeTab: "quick",
19 error: "",
20 tagsError: "",
21 info: null,
22 availableTagOptions: [],
23 higherMajorVersions: [],
24 + majorUpgradeVersions: [],
25 restartStatusText: "",
26 restartDetailText: "",
27 form: {
@@ -37,18 +39,62 @@ const model = {
39 return this.loading || this.saving || this.restarting;
40 },
41
42 + get hasPendingInitialLoad() {
43 + return !this.info && !this.error;
44 + },
45 +
46 + get isCheckingStatus() {
47 + return this.loading || this.hasPendingInitialLoad;
48 + },
49 +
50 get isSupported() {
51 return Boolean(this.info?.supported);
52 },
53
54 get currentVersion() {
45 - return this.info?.current?.display_version || this.info?.current?.short_tag || "unknown";
55 + return (
56 + this.info?.current?.display_version ||
57 + this.info?.current?.short_tag ||
58 + (this.hasPendingInitialLoad ? "Loading" : "unknown")
59 + );
60 },
61
62 get currentBranch() {
63 return this.info?.current?.branch || "";
64 },
65
66 + get currentComparableVersion() {
67 + return this.info?.current?.short_tag || "";
68 + },
69 +
70 + get mainBranchLatestTag() {
71 + return this.info?.main_branch_latest?.short_tag || "";
72 + },
73 +
74 + get mainBranchLatestVersion() {
75 + return (
76 + this.info?.main_branch_latest?.display_version ||
77 + this.info?.main_branch_latest?.short_tag ||
78 + (this.hasPendingInitialLoad ? "Loading" : "Unavailable")
79 + );
80 + },
81 +
82 + get mainBranchLatestCommit() {
83 + return this.info?.main_branch_latest?.short_commit || "";
84 + },
85 +
86 + get mainBranchLatestSupported() {
87 + return Boolean(this.info?.main_branch_latest?.supported);
88 + },
89 +
90 + get currentReleasedAt() {
91 + return this.info?.current?.released_at || "";
92 + },
93 +
94 + get mainBranchLatestReleasedAt() {
95 + return this.info?.main_branch_latest?.released_at || "";
96 + },
97 +
98 get trimmedTag() {
99 return (this.form.tag || "").trim();
100 },
@@ -78,6 +124,20 @@ const model = {
124 return `A newer major release line is available on this branch (${versionText}). Major upgrades require downloading a newer Docker image before using self-update.`;
125 },
126
127 + get hasMajorUpgrade() {
128 + return this.majorUpgradeVersions.length > 0;
129 + },
130 +
131 + get majorUpgradeBannerMessage() {
132 + if (!this.majorUpgradeVersions.length) return "";
133 + const versionLabels = this.majorUpgradeVersions.map((major) => `v${major}.x`);
134 + const versionText =
135 + versionLabels.length === 1
136 + ? versionLabels[0]
137 + : `${versionLabels.slice(0, -1).join(", ")} and ${versionLabels[versionLabels.length - 1]}`;
138 + return `A newer major release line is available (${versionText}). This self-updater keeps showing only updates from the current major version. Major upgrades require a new Docker image and data migration.`;
139 + },
140 +
141 get versionSelectPlaceholder() {
142 if (this.tagsLoading) return "Loading versions...";
143 if (!this.hasAvailableTags) return "No versions available";
@@ -95,10 +155,157 @@ const model = {
155 );
156 },
157
158 + get quickUpdateComparison() {
159 + return this.compareSelectorVersions(
160 + this.mainBranchLatestTag,
161 + this.currentComparableVersion,
162 + );
163 + },
164 +
165 + get quickUpdateAvailable() {
166 + return (
167 + this.isSupported &&
168 + !this.isBusy &&
169 + this.mainBranchLatestSupported &&
170 + this.quickUpdateComparison !== null &&
171 + this.quickUpdateComparison > 0
172 + );
173 + },
174 +
175 + get quickStatusLabel() {
176 + if (this.isCheckingStatus) return "CHECKING";
177 + if (!this.isSupported) return "UNAVAILABLE";
178 + if (!this.mainBranchLatestSupported) return "MAIN UNAVAILABLE";
179 + if (!this.mainBranchLatestTag) return "UNAVAILABLE";
180 + if (this.quickUpdateComparison === null) return "REVIEW";
181 + if (this.quickUpdateComparison > 0) return "UPDATE AVAILABLE";
182 + if (this.quickUpdateComparison === 0) return "UP TO DATE";
183 + return "AHEAD OF MAIN";
184 + },
185 +
186 + get quickBehindMinorCount() {
187 + const latest = this.parseSelectorTag(this.mainBranchLatestTag);
188 + const current = this.parseSelectorTag(this.currentComparableVersion);
189 + if (!latest || !current || this.quickUpdateComparison === null || this.quickUpdateComparison <= 0) {
190 + return null;
191 + }
192 + if (latest[0] !== current[0]) {
193 + return 4;
194 + }
195 + return latest[1] - current[1];
196 + },
197 +
198 + get quickStatusMessage() {
199 + if (this.isCheckingStatus) {
200 + return "Checking update status...";
201 + }
202 + if (!this.isSupported) {
203 + return "Self-update is currently available only in dockerized Agent Zero deployments that boot through /exe/run_A0.sh.";
204 + }
205 + if (!this.mainBranchLatestSupported) {
206 + return "The main branch is not currently available from the configured remote.";
207 + }
208 + if (!this.mainBranchLatestTag) {
209 + return "No supported main-branch version could be resolved right now.";
210 + }
211 + if (this.quickUpdateComparison === null) {
212 + return "The current checkout does not expose a comparable tagged version. Use Advanced if you still want to choose a target manually.";
213 + }
214 + if (this.quickUpdateComparison > 0) {
215 + if (this.quickBehindMinorCount !== null && this.quickBehindMinorCount <= 3) {
216 + return `This instance is ${this.quickBehindMinorCount} minor version${this.quickBehindMinorCount === 1 ? "" : "s"} behind the latest release currently available on main.`;
217 + }
218 + return `This instance is significantly behind the latest release currently available on main. Restart Agent Zero to move to ${this.mainBranchLatestVersion}.`;
219 + }
220 + if (this.quickUpdateComparison === 0) {
221 + return "This instance already matches the latest version currently available on main.";
222 + }
223 + return "This checkout already reports a newer tagged version than main.";
224 + },
225 +
226 + get quickStatusBadgeClass() {
227 + if (this.isCheckingStatus) {
228 + return "status-pill-neutral";
229 + }
230 + if (!this.isSupported || !this.mainBranchLatestSupported || !this.mainBranchLatestTag) {
231 + return "status-pill-neutral";
232 + }
233 + if (this.quickUpdateComparison === null) {
234 + return "status-pill-neutral";
235 + }
236 + if (this.quickUpdateComparison === 0) {
237 + return "status-pill-success";
238 + }
239 + if (this.quickUpdateComparison > 0) {
240 + return this.quickBehindMinorCount !== null && this.quickBehindMinorCount <= 3
241 + ? "status-pill-info"
242 + : "status-pill-warning";
243 + }
244 + return "status-pill-neutral";
245 + },
246 +
247 + get quickComparisonIcon() {
248 + if (this.isCheckingStatus) return "progress_activity";
249 + if (!this.isSupported || !this.mainBranchLatestSupported || !this.mainBranchLatestTag) {
250 + return "help";
251 + }
252 + if (this.quickUpdateComparison === null) {
253 + return "help";
254 + }
255 + if (this.quickUpdateComparison === 0) {
256 + return "task_alt";
257 + }
258 + if (this.quickUpdateComparison > 0) {
259 + return this.quickBehindMinorCount !== null && this.quickBehindMinorCount <= 3
260 + ? "update"
261 + : "warning";
262 + }
263 + return "north_east";
264 + },
265 +
266 + get quickComparisonIconClass() {
267 + if (this.isCheckingStatus) {
268 + return "self-update-quick-icon-neutral";
269 + }
270 + if (!this.isSupported || !this.mainBranchLatestSupported || !this.mainBranchLatestTag) {
271 + return "self-update-quick-icon-neutral";
272 + }
273 + if (this.quickUpdateComparison === null) {
274 + return "self-update-quick-icon-neutral";
275 + }
276 + if (this.quickUpdateComparison === 0) {
277 + return "self-update-quick-icon-success";
278 + }
279 + if (this.quickUpdateComparison > 0) {
280 + return this.quickBehindMinorCount !== null && this.quickBehindMinorCount <= 3
281 + ? "self-update-quick-icon-info"
282 + : "self-update-quick-icon-warning";
283 + }
284 + return "self-update-quick-icon-neutral";
285 + },
286 +
287 + get quickMajorUpgradeNotice() {
288 + const latest = this.parseSelectorTag(this.mainBranchLatestTag);
289 + const current = this.parseSelectorTag(this.currentComparableVersion);
290 + if (!latest || !current || latest[0] === current[0]) {
291 + return "";
292 + }
293 + return (
294 + "This update crosses into a newer major release line. If your Docker image is older, " +
295 + "you may still need to update the image itself after applying the repo update."
296 + );
297 + },
298 +
299 async init() {
300 await this.refresh();
301 },
302
303 + setTab(tab) {
304 + if (tab === "quick" || tab === "advanced") {
305 + this.activeTab = tab;
306 + }
307 + },
308 +
309 cleanup() {
310 this.clearReconnectTimer();
311 this.error = "";
@@ -109,6 +316,7 @@ const model = {
316 this.tagsLoading = false;
317 this.availableTagOptions = [];
318 this.higherMajorVersions = [];
319 + this.majorUpgradeVersions = [];
320 this.restartStatusText = "";
321 this.restartDetailText = "";
322 this.removeProgressOverlay();
@@ -130,6 +338,19 @@ const model = {
338 }
339 },
340
341 + formatReleaseTimestamp(value) {
342 + if (!value) {
343 + if (this.hasPendingInitialLoad) {
344 + return "Loading";
345 + }
346 + return "Release date unavailable";
347 + }
348 + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)) {
349 + return value;
350 + }
351 + return this.formatTimestamp(value);
352 + },
353 +
354 formatBranchTag(branch, tag) {
355 return `${branch || "main"} / ${tag || "None"}`;
356 },
@@ -283,6 +504,9 @@ const model = {
504 throw new Error(response?.error || "Failed to load self-update info.");
505 }
506 this.info = response;
507 + this.majorUpgradeVersions = Array.isArray(response.major_upgrade_versions)
508 + ? response.major_upgrade_versions
509 + : [];
510 this.applyFormState(
511 response.pending || {
512 ...(response.defaults || {}),
@@ -413,35 +637,22 @@ const model = {
637 return this.isLatestSelectorTag(value) || this.isSupportedSelectorTag(value);
638 },
639
416 - async scheduleUpdate() {
417 - if (!this.form.branch?.trim()) {
418 - this.error = "Choose a branch.";
419 - return;
420 - }
421 -
422 - if (!this.form.tag?.trim()) {
423 - this.error = "Choose a version from the list.";
424 - return;
425 - }
426 -
427 - if (!this.isLatestSelectorTag(this.form.tag) && !this.parseSelectorTag(this.form.tag)) {
428 - this.error = "Release tag must use the format vX.Y.";
429 - return;
640 + compareSelectorVersions(left, right) {
641 + const leftVersion = this.parseSelectorTag(left);
642 + const rightVersion = this.parseSelectorTag(right);
643 + if (!leftVersion || !rightVersion) {
644 + return null;
645 }
431 -
432 - if (!this.isLatestSelectorTag(this.form.tag) && !this.isSupportedSelectorTag(this.form.tag)) {
433 - this.error = "Release tag must be v1.0 or newer.";
434 - return;
646 + if (leftVersion[0] !== rightVersion[0]) {
647 + return leftVersion[0] - rightVersion[0];
648 }
436 -
437 - if (!this.selectedTagExistsOnBranch) {
438 - await this.fetchTags();
439 - if (!this.selectedTagExistsOnBranch) {
440 - this.error = `Version ${this.trimmedTag} does not exist on branch ${this.form.branch || "main"}.`;
441 - return;
442 - }
649 + if (leftVersion[1] !== rightVersion[1]) {
650 + return leftVersion[1] - rightVersion[1];
651 }
652 + return 0;
653 + },
654
655 + async scheduleUpdateRequest(payload, notificationMessage) {
656 this.saving = true;
657 this.error = "";
658 this.setRestartState(
@@ -450,14 +661,7 @@ const model = {
661 );
662 this.ensureProgressOverlay();
663 try {
453 - const response = await API.callJsonApi("self_update_schedule", {
454 - branch: this.form.branch,
455 - tag: this.form.tag,
456 - backup_usr: this.form.backup_usr,
457 - backup_path: this.form.backup_path,
458 - backup_name: this.form.backup_name,
459 - backup_conflict_policy: this.form.backup_conflict_policy,
460 - });
664 + const response = await API.callJsonApi("self_update_schedule", payload);
665 if (!response?.success) {
666 throw new Error(response?.error || "Failed to schedule the self-update.");
667 }
@@ -466,7 +670,7 @@ const model = {
670 this.info.pending = response.pending;
671 }
672 notificationStore.frontendWarning(
469 - "Agent Zero is restarting to apply the requested branch and version target.",
673 + notificationMessage,
674 "Self Update",
675 10,
676 "self-update-restart",
@@ -487,6 +691,78 @@ const model = {
691 }
692 },
693
694 + async scheduleUpdate() {
695 + if (!this.form.branch?.trim()) {
696 + this.error = "Choose a branch.";
697 + return;
698 + }
699 +
700 + if (!this.form.tag?.trim()) {
701 + this.error = "Choose a version from the list.";
702 + return;
703 + }
704 +
705 + if (!this.isLatestSelectorTag(this.form.tag) && !this.parseSelectorTag(this.form.tag)) {
706 + this.error = "Release tag must use the format vX.Y.";
707 + return;
708 + }
709 +
710 + if (!this.isLatestSelectorTag(this.form.tag) && !this.isSupportedSelectorTag(this.form.tag)) {
711 + this.error = "Release tag must be v1.0 or newer.";
712 + return;
713 + }
714 +
715 + if (!this.selectedTagExistsOnBranch) {
716 + await this.fetchTags();
717 + if (!this.selectedTagExistsOnBranch) {
718 + this.error = `Version ${this.trimmedTag} does not exist on branch ${this.form.branch || "main"}.`;
719 + return;
720 + }
721 + }
722 +
723 + await this.scheduleUpdateRequest(
724 + {
725 + branch: this.form.branch,
726 + tag: this.form.tag,
727 + backup_usr: this.form.backup_usr,
728 + backup_path: this.form.backup_path,
729 + backup_name: this.form.backup_name,
730 + backup_conflict_policy: this.form.backup_conflict_policy,
731 + },
732 + "Agent Zero is restarting to apply the requested branch and version target.",
733 + );
734 + },
735 +
736 + async scheduleQuickUpdate() {
737 + if (!this.mainBranchLatestSupported || !this.mainBranchLatestTag) {
738 + this.error = "Latest main-branch version is not available right now.";
739 + return;
740 + }
741 +
742 + if (this.quickUpdateComparison === null) {
743 + this.error =
744 + "The current checkout cannot be compared to the latest main version. Use Advanced to choose a version manually.";
745 + return;
746 + }
747 +
748 + if (this.quickUpdateComparison <= 0) {
749 + return;
750 + }
751 +
752 + await this.scheduleUpdateRequest(
753 + {
754 + branch: "main",
755 + tag: this.mainBranchLatestTag,
756 + backup_usr: this.info?.defaults?.backup_usr ?? true,
757 + backup_path: this.info?.defaults?.backup_path || "",
758 + backup_name: this.info?.defaults?.backup_name || "",
759 + backup_conflict_policy:
760 + this.info?.defaults?.backup_conflict_policy || "rename",
761 + },
762 + "Agent Zero is restarting to apply the latest version from main.",
763 + );
764 + },
765 +
766 async restartAndReload() {
767 this.restarting = true;
768 this.clearReconnectTimer();