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();