main
js 895 lines 26.9 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import * as API from "/js/api.js";
3 import { store as notificationStore } from "/components/notifications/notification-store.js";
4 import { openModal, closeModal } from "/js/modals.js";
5 import { formatDateTime } from "/js/time-utils.js";
6
7 const HEALTH_POLL_INTERVAL_MS = 2000;
8 const HEALTH_WAIT_BUFFER_MS = 30000;
9 const SELF_UPDATE_OVERLAY_ID = "self-update-progress-overlay";
10 const SELF_UPDATE_MODAL_PATH = "settings/external/self-update-modal.html";
11 const SELF_UPDATE_MANUAL_BACKUP_MODAL_PATH = "settings/backup/backup_restore.html";
12 const MIN_SELECTOR_VERSION = [1, 0];
13
14 const model = {
15 loading: false,
16 saving: false,
17 restarting: false,
18 tagsLoading: false,
19 activeTab: "quick",
20 error: "",
21 tagsError: "",
22 info: null,
23 availableTagOptions: [],
24 higherMajorVersions: [],
25 majorUpgradeVersions: [],
26 restartStatusText: "",
27 restartDetailText: "",
28 form: {
29 branch: "main",
30 tag: "",
31 backup_usr: true,
32 backup_path: "",
33 backup_name: "",
34 backup_conflict_policy: "rename",
35 },
36 _reconnectTimer: null,
37 _tagRequestId: 0,
38
39 get isBusy() {
40 return this.loading || this.saving || this.restarting;
41 },
42
43 get hasPendingInitialLoad() {
44 return !this.info && !this.error;
45 },
46
47 get isCheckingStatus() {
48 return this.loading || this.hasPendingInitialLoad;
49 },
50
51 get isSupported() {
52 return Boolean(this.info?.supported);
53 },
54
55 get currentVersion() {
56 return (
57 this.info?.current?.display_version ||
58 this.info?.current?.short_tag ||
59 (this.hasPendingInitialLoad ? "Loading" : "unknown")
60 );
61 },
62
63 get currentBranch() {
64 return this.info?.current?.branch || "";
65 },
66
67 get currentComparableVersion() {
68 return this.info?.current?.short_tag || "";
69 },
70
71 get mainBranchLatestTag() {
72 return this.info?.main_branch_latest?.short_tag || "";
73 },
74
75 get mainBranchLatestVersion() {
76 return (
77 this.info?.main_branch_latest?.display_version ||
78 this.info?.main_branch_latest?.short_tag ||
79 (this.hasPendingInitialLoad ? "Loading" : "Unavailable")
80 );
81 },
82
83 get mainBranchLatestCommit() {
84 return this.info?.main_branch_latest?.short_commit || "";
85 },
86
87 get mainBranchLatestSupported() {
88 return Boolean(this.info?.main_branch_latest?.supported);
89 },
90
91 get currentReleasedAt() {
92 return this.info?.current?.released_at || "";
93 },
94
95 get mainBranchLatestReleasedAt() {
96 return this.info?.main_branch_latest?.released_at || "";
97 },
98
99 get trimmedTag() {
100 return (this.form.tag || "").trim();
101 },
102
103 get hasAvailableTags() {
104 return this.availableTagOptions.length > 0;
105 },
106
107 get availableTags() {
108 return this.availableTagOptions
109 .map((option) => option?.value || "")
110 .filter(Boolean);
111 },
112
113 get selectedTagExistsOnBranch() {
114 const tag = this.trimmedTag;
115 return Boolean(tag) && this.availableTags.includes(tag);
116 },
117
118 get higherMajorVersionMessage() {
119 if (!this.higherMajorVersions.length) return "";
120 const versionLabels = this.higherMajorVersions.map((major) => `v${major}.x`);
121 const versionText =
122 versionLabels.length === 1
123 ? versionLabels[0]
124 : `${versionLabels.slice(0, -1).join(", ")} and ${versionLabels[versionLabels.length - 1]}`;
125 return `A newer major release line is available on this branch (${versionText}). Major upgrades require downloading a newer Docker image before using self-update.`;
126 },
127
128 get hasMajorUpgrade() {
129 return this.majorUpgradeVersions.length > 0;
130 },
131
132 get majorUpgradeBannerMessage() {
133 if (!this.majorUpgradeVersions.length) return "";
134 const versionLabels = this.majorUpgradeVersions.map((major) => `v${major}.x`);
135 const versionText =
136 versionLabels.length === 1
137 ? versionLabels[0]
138 : `${versionLabels.slice(0, -1).join(", ")} and ${versionLabels[versionLabels.length - 1]}`;
139 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.`;
140 },
141
142 get versionSelectPlaceholder() {
143 if (this.tagsLoading) return "Loading versions...";
144 if (!this.hasAvailableTags) return "No versions available";
145 return "Select a version";
146 },
147
148 get canScheduleUpdate() {
149 return (
150 this.isSupported &&
151 !this.isBusy &&
152 !this.tagsLoading &&
153 this.hasAvailableTags &&
154 this.isSelectableTag(this.form.tag) &&
155 this.selectedTagExistsOnBranch
156 );
157 },
158
159 get quickUpdateComparison() {
160 return this.compareSelectorVersions(
161 this.mainBranchLatestTag,
162 this.currentComparableVersion,
163 );
164 },
165
166 get quickUpdateAvailable() {
167 return (
168 this.isSupported &&
169 !this.isBusy &&
170 this.mainBranchLatestSupported &&
171 this.quickUpdateComparison !== null &&
172 this.quickUpdateComparison > 0
173 );
174 },
175
176 get quickStatusLabel() {
177 if (this.isCheckingStatus) return "CHECKING";
178 if (!this.isSupported) return "UNAVAILABLE";
179 if (!this.mainBranchLatestSupported) return "MAIN UNAVAILABLE";
180 if (!this.mainBranchLatestTag) return "UNAVAILABLE";
181 if (this.quickUpdateComparison === null) return "REVIEW";
182 if (this.quickUpdateComparison > 0) return "UPDATE AVAILABLE";
183 if (this.quickUpdateComparison === 0) return "UP TO DATE";
184 return "AHEAD OF MAIN";
185 },
186
187 get quickBehindMinorCount() {
188 const latest = this.parseSelectorTag(this.mainBranchLatestTag);
189 const current = this.parseSelectorTag(this.currentComparableVersion);
190 if (!latest || !current || this.quickUpdateComparison === null || this.quickUpdateComparison <= 0) {
191 return null;
192 }
193 if (latest[0] !== current[0]) {
194 return 4;
195 }
196 return latest[1] - current[1];
197 },
198
199 get quickStatusMessage() {
200 if (this.isCheckingStatus) {
201 return "Checking update status...";
202 }
203 if (!this.isSupported) {
204 return "Self-update is currently available only in dockerized Agent Zero deployments that boot through /exe/run_A0.sh.";
205 }
206 if (!this.mainBranchLatestSupported) {
207 return "The main branch is not currently available from the configured remote.";
208 }
209 if (!this.mainBranchLatestTag) {
210 return "No supported main-branch version could be resolved right now.";
211 }
212 if (this.quickUpdateComparison === null) {
213 return "The current checkout does not expose a comparable tagged version. Use Advanced if you still want to choose a target manually.";
214 }
215 if (this.quickUpdateComparison > 0) {
216 if (this.quickBehindMinorCount !== null && this.quickBehindMinorCount <= 3) {
217 return `This instance is ${this.quickBehindMinorCount} minor version${this.quickBehindMinorCount === 1 ? "" : "s"} behind the latest release currently available on main.`;
218 }
219 return `This instance is significantly behind the latest release currently available on main. Restart Agent Zero to move to ${this.mainBranchLatestVersion}.`;
220 }
221 if (this.quickUpdateComparison === 0) {
222 return "You already have the latest version of Agent Zero main branch";
223 }
224 return "This checkout already reports a newer tagged version than main.";
225 },
226
227 get quickStatusBadgeClass() {
228 if (this.isCheckingStatus) {
229 return "status-pill-neutral";
230 }
231 if (!this.isSupported || !this.mainBranchLatestSupported || !this.mainBranchLatestTag) {
232 return "status-pill-neutral";
233 }
234 if (this.quickUpdateComparison === null) {
235 return "status-pill-neutral";
236 }
237 if (this.quickUpdateComparison === 0) {
238 return "status-pill-success";
239 }
240 if (this.quickUpdateComparison > 0) {
241 return this.quickBehindMinorCount !== null && this.quickBehindMinorCount <= 3
242 ? "status-pill-info"
243 : "status-pill-warning";
244 }
245 return "status-pill-neutral";
246 },
247
248 get quickComparisonIcon() {
249 if (this.isCheckingStatus) return "progress_activity";
250 if (!this.isSupported || !this.mainBranchLatestSupported || !this.mainBranchLatestTag) {
251 return "help";
252 }
253 if (this.quickUpdateComparison === null) {
254 return "help";
255 }
256 if (this.quickUpdateComparison === 0) {
257 return "task_alt";
258 }
259 if (this.quickUpdateComparison > 0) {
260 return this.quickBehindMinorCount !== null && this.quickBehindMinorCount <= 3
261 ? "update"
262 : "warning";
263 }
264 return "north_east";
265 },
266
267 get quickComparisonIconClass() {
268 if (this.isCheckingStatus) {
269 return "self-update-quick-icon-neutral";
270 }
271 if (!this.isSupported || !this.mainBranchLatestSupported || !this.mainBranchLatestTag) {
272 return "self-update-quick-icon-neutral";
273 }
274 if (this.quickUpdateComparison === null) {
275 return "self-update-quick-icon-neutral";
276 }
277 if (this.quickUpdateComparison === 0) {
278 return "self-update-quick-icon-success";
279 }
280 if (this.quickUpdateComparison > 0) {
281 return this.quickBehindMinorCount !== null && this.quickBehindMinorCount <= 3
282 ? "self-update-quick-icon-info"
283 : "self-update-quick-icon-warning";
284 }
285 return "self-update-quick-icon-neutral";
286 },
287
288 get quickMajorUpgradeNotice() {
289 const latest = this.parseSelectorTag(this.mainBranchLatestTag);
290 const current = this.parseSelectorTag(this.currentComparableVersion);
291 if (!latest || !current || latest[0] === current[0]) {
292 return "";
293 }
294 return (
295 "This update crosses into a newer major release line. If your Docker image is older, " +
296 "you may still need to update the image itself after applying the repo update."
297 );
298 },
299
300 async init() {
301 await this.refresh();
302 },
303
304 setTab(tab) {
305 if (tab === "quick" || tab === "advanced") {
306 this.activeTab = tab;
307 }
308 },
309
310 cleanup() {
311 this.clearReconnectTimer();
312 this.error = "";
313 this.tagsError = "";
314 this.loading = false;
315 this.saving = false;
316 this.restarting = false;
317 this.tagsLoading = false;
318 this.availableTagOptions = [];
319 this.higherMajorVersions = [];
320 this.majorUpgradeVersions = [];
321 this.restartStatusText = "";
322 this.restartDetailText = "";
323 this.removeProgressOverlay();
324 },
325
326 clearReconnectTimer() {
327 if (this._reconnectTimer) {
328 clearTimeout(this._reconnectTimer);
329 this._reconnectTimer = null;
330 }
331 },
332
333 formatTimestamp(value) {
334 if (!value) return "";
335 try {
336 return formatDateTime(value, "full");
337 } catch {
338 return value;
339 }
340 },
341
342 formatReleaseTimestamp(value) {
343 if (!value) {
344 if (this.hasPendingInitialLoad) {
345 return "Loading";
346 }
347 return "Release date unavailable";
348 }
349 if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)) {
350 return value;
351 }
352 return this.formatTimestamp(value);
353 },
354
355 formatBranchTag(branch, tag) {
356 return `${branch || "main"} / ${tag || "None"}`;
357 },
358
359 normalizeLastStatus(status) {
360 return (status || "unknown").trim().toLowerCase();
361 },
362
363 getLastStatusLabel(status) {
364 const normalizedStatus = this.normalizeLastStatus(status);
365 return normalizedStatus ? normalizedStatus.replace(/_/g, " ") : "unknown";
366 },
367
368 getLastStatusBadgeClass(status) {
369 const normalizedStatus = this.normalizeLastStatus(status);
370 if (normalizedStatus === "success") {
371 return "status-pill-success";
372 }
373 if (normalizedStatus === "failed" || normalizedStatus === "rollback_failed") {
374 return "status-pill-error";
375 }
376 if (normalizedStatus === "rolled_back") {
377 return "status-pill-warning";
378 }
379 return "status-pill-neutral";
380 },
381
382 getProgressOverlay() {
383 return document.getElementById(SELF_UPDATE_OVERLAY_ID);
384 },
385
386 ensureProgressOverlay() {
387 let overlay = this.getProgressOverlay();
388 if (!overlay) {
389 overlay = document.createElement("div");
390 overlay.id = SELF_UPDATE_OVERLAY_ID;
391 overlay.innerHTML = `
392 <div class="self-update-progress-card">
393 <div class="self-update-progress-spinner"></div>
394 <div class="self-update-progress-title"></div>
395 <div class="self-update-progress-detail"></div>
396 </div>
397 `;
398 Object.assign(overlay.style, {
399 position: "fixed",
400 inset: "0",
401 zIndex: "10000",
402 display: "flex",
403 alignItems: "center",
404 justifyContent: "center",
405 padding: "1.5rem",
406 background:
407 "color-mix(in srgb, var(--color-background) 74%, transparent)",
408 backdropFilter: "blur(6px)",
409 });
410 document.body.appendChild(overlay);
411
412 const style = document.createElement("style");
413 style.id = `${SELF_UPDATE_OVERLAY_ID}-styles`;
414 style.textContent = `
415 #${SELF_UPDATE_OVERLAY_ID} .self-update-progress-card {
416 width: min(28rem, calc(100vw - 2rem));
417 border-radius: 1rem;
418 border: 1px solid var(--color-border);
419 background: color-mix(
420 in srgb,
421 var(--color-panel) 92%,
422 var(--color-background)
423 );
424 color: var(--color-text);
425 box-shadow: 0 24px 64px color-mix(
426 in srgb,
427 var(--color-background) 65%,
428 transparent
429 );
430 padding: 1.5rem;
431 text-align: center;
432 font-family: var(--font-family-main);
433 }
434 #${SELF_UPDATE_OVERLAY_ID} .self-update-progress-spinner {
435 width: 2.5rem;
436 height: 2.5rem;
437 margin: 0 auto 1rem;
438 border-radius: 999px;
439 border: 3px solid color-mix(
440 in srgb,
441 var(--color-border) 55%,
442 transparent
443 );
444 border-top-color: var(--color-primary);
445 animation: self-update-spin 1s linear infinite;
446 }
447 #${SELF_UPDATE_OVERLAY_ID} .self-update-progress-title {
448 font-size: 1.05rem;
449 font-weight: 700;
450 margin-bottom: 0.5rem;
451 }
452 #${SELF_UPDATE_OVERLAY_ID} .self-update-progress-detail {
453 color: var(--color-text-muted);
454 line-height: 1.5;
455 }
456 @keyframes self-update-spin {
457 from { transform: rotate(0deg); }
458 to { transform: rotate(360deg); }
459 }
460 `;
461 document.head.appendChild(style);
462 }
463 this.updateProgressOverlay();
464 },
465
466 updateProgressOverlay() {
467 const overlay = this.getProgressOverlay();
468 if (!overlay) return;
469 const title = overlay.querySelector(".self-update-progress-title");
470 const detail = overlay.querySelector(".self-update-progress-detail");
471 if (title) {
472 title.textContent = this.restartStatusText || "Applying self-update";
473 }
474 if (detail) {
475 detail.textContent =
476 this.restartDetailText ||
477 "Agent Zero is restarting, applying the requested release, and will reload this page when the health check responds again.";
478 }
479 },
480
481 removeProgressOverlay() {
482 this.getProgressOverlay()?.remove();
483 document.getElementById(`${SELF_UPDATE_OVERLAY_ID}-styles`)?.remove();
484 },
485
486 resetRestartState() {
487 this.restartStatusText = "";
488 this.restartDetailText = "";
489 },
490
491 setRestartState(statusText, detailText = "") {
492 this.restartStatusText = statusText;
493 this.restartDetailText = detailText;
494 if (this.restarting) {
495 this.ensureProgressOverlay();
496 }
497 },
498
499 async refresh() {
500 this.loading = true;
501 this.error = "";
502 try {
503 const response = await API.callJsonApi("self_update_get", {});
504 if (!response?.success) {
505 throw new Error(response?.error || "Failed to load self-update info.");
506 }
507 this.info = response;
508 this.majorUpgradeVersions = Array.isArray(response.major_upgrade_versions)
509 ? response.major_upgrade_versions
510 : [];
511 this.applyFormState(
512 response.pending || {
513 ...(response.defaults || {}),
514 tag: "",
515 },
516 );
517 this.applyAvailableTags({
518 options: response.available_tag_options,
519 higherMajorVersions: response.available_higher_major_versions,
520 error: response.available_tags_error,
521 });
522 } catch (error) {
523 console.error("Failed to load self-update info:", error);
524 this.error = error.message || "Failed to load self-update info.";
525 } finally {
526 this.loading = false;
527 }
528 },
529
530 applyFormState(source) {
531 this.form.branch =
532 source?.branch ||
533 this.info?.defaults?.branch ||
534 this.currentBranch ||
535 "main";
536 this.form.tag =
537 typeof source?.tag === "string"
538 ? source.tag
539 : this.info?.defaults?.tag || this.currentVersion;
540 this.form.backup_usr =
541 typeof source?.backup_usr === "boolean" ? source.backup_usr : true;
542 this.form.backup_path = source?.backup_path || "";
543 this.form.backup_name = source?.backup_name || "";
544 this.form.backup_conflict_policy =
545 source?.backup_conflict_policy || "rename";
546 },
547
548 applyAvailableTags({ options = [], higherMajorVersions = [], error = "" } = {}) {
549 this.availableTagOptions = Array.isArray(options) ? options : [];
550 this.higherMajorVersions = Array.isArray(higherMajorVersions)
551 ? higherMajorVersions
552 : [];
553 this.tagsError = error || "";
554
555 if (!this.availableTags.length) {
556 this.form.tag = "";
557 return;
558 }
559
560 const preferredTag = this.trimmedTag;
561 if (preferredTag && this.availableTags.includes(preferredTag)) {
562 return;
563 }
564
565 this.form.tag = "";
566 },
567
568 async openModal() {
569 await openModal(SELF_UPDATE_MODAL_PATH);
570 },
571
572 async openManualBackupModal() {
573 await openModal(SELF_UPDATE_MANUAL_BACKUP_MODAL_PATH);
574 },
575
576 async onBranchChanged() {
577 await this.fetchTags();
578 },
579
580 async fetchTags() {
581 const requestId = ++this._tagRequestId;
582 this.tagsLoading = true;
583 this.tagsError = "";
584
585 try {
586 const response = await API.callJsonApi("self_update_tags", {
587 branch: this.form.branch,
588 });
589 if (!response?.success) {
590 throw new Error(response?.error || "Failed to fetch release tags.");
591 }
592 if (requestId !== this._tagRequestId) {
593 return;
594 }
595 this.applyAvailableTags({
596 options: response.tag_options,
597 higherMajorVersions: response.higher_major_versions,
598 error: response.error,
599 });
600 } catch (error) {
601 console.error("Failed to fetch self-update tags:", error);
602 if (requestId !== this._tagRequestId) {
603 return;
604 }
605 this.applyAvailableTags();
606 this.tagsError = error.message || "Failed to fetch release tags.";
607 } finally {
608 if (requestId === this._tagRequestId) {
609 this.tagsLoading = false;
610 }
611 }
612 },
613
614 parseSelectorTag(value) {
615 const match = /^v(\d+)\.(\d+)$/.exec((value || "").trim());
616 if (!match) return null;
617 return [
618 Number.parseInt(match[1], 10),
619 Number.parseInt(match[2], 10),
620 ];
621 },
622
623 isSupportedSelectorTag(value) {
624 const parsed = this.parseSelectorTag(value);
625 if (!parsed) return false;
626 for (let i = 0; i < MIN_SELECTOR_VERSION.length; i += 1) {
627 if (parsed[i] > MIN_SELECTOR_VERSION[i]) return true;
628 if (parsed[i] < MIN_SELECTOR_VERSION[i]) return false;
629 }
630 return true;
631 },
632
633 isLatestSelectorTag(value) {
634 return (value || "").trim().toLowerCase() === "latest";
635 },
636
637 isSelectableTag(value) {
638 return this.isLatestSelectorTag(value) || this.isSupportedSelectorTag(value);
639 },
640
641 compareSelectorVersions(left, right) {
642 const leftVersion = this.parseSelectorTag(left);
643 const rightVersion = this.parseSelectorTag(right);
644 if (!leftVersion || !rightVersion) {
645 return null;
646 }
647 if (leftVersion[0] !== rightVersion[0]) {
648 return leftVersion[0] - rightVersion[0];
649 }
650 if (leftVersion[1] !== rightVersion[1]) {
651 return leftVersion[1] - rightVersion[1];
652 }
653 return 0;
654 },
655
656 async scheduleUpdateRequest(payload, notificationMessage) {
657 this.saving = true;
658 this.error = "";
659 this.setRestartState(
660 "Preparing update",
661 "Saving the request and asking Agent Zero to restart."
662 );
663 this.ensureProgressOverlay();
664 try {
665 const response = await API.callJsonApi("self_update_schedule", payload);
666 if (!response?.success) {
667 throw new Error(response?.error || "Failed to schedule the self-update.");
668 }
669
670 if (this.info) {
671 this.info.pending = response.pending;
672 }
673 notificationStore.frontendWarning(
674 notificationMessage,
675 "Self Update",
676 10,
677 "self-update-restart",
678 undefined,
679 true,
680 ).catch((warningError) => {
681 console.error("Failed to show self-update warning toast:", warningError);
682 });
683 await this.restartAndReload();
684 } catch (error) {
685 console.error("Failed to schedule self-update:", error);
686 this.restarting = false;
687 this.resetRestartState();
688 this.removeProgressOverlay();
689 this.error = error.message || "Failed to schedule the self-update.";
690 } finally {
691 this.saving = false;
692 }
693 },
694
695 async scheduleUpdate() {
696 if (!this.form.branch?.trim()) {
697 this.error = "Choose a branch.";
698 return;
699 }
700
701 if (!this.form.tag?.trim()) {
702 this.error = "Choose a version from the list.";
703 return;
704 }
705
706 if (!this.isLatestSelectorTag(this.form.tag) && !this.parseSelectorTag(this.form.tag)) {
707 this.error = "Release tag must use the format vX.Y.";
708 return;
709 }
710
711 if (!this.isLatestSelectorTag(this.form.tag) && !this.isSupportedSelectorTag(this.form.tag)) {
712 this.error = "Release tag must be v1.0 or newer.";
713 return;
714 }
715
716 if (!this.selectedTagExistsOnBranch) {
717 await this.fetchTags();
718 if (!this.selectedTagExistsOnBranch) {
719 this.error = `Version ${this.trimmedTag} does not exist on branch ${this.form.branch || "main"}.`;
720 return;
721 }
722 }
723
724 await this.scheduleUpdateRequest(
725 {
726 branch: this.form.branch,
727 tag: this.form.tag,
728 backup_usr: this.form.backup_usr,
729 backup_path: this.form.backup_path,
730 backup_name: this.form.backup_name,
731 backup_conflict_policy: this.form.backup_conflict_policy,
732 },
733 "Agent Zero is restarting to apply the requested branch and version target.",
734 );
735 },
736
737 async scheduleQuickUpdate() {
738 if (!this.mainBranchLatestSupported || !this.mainBranchLatestTag) {
739 this.error = "Latest main-branch version is not available right now.";
740 return;
741 }
742
743 if (this.quickUpdateComparison === null) {
744 this.error =
745 "The current checkout cannot be compared to the latest main version. Use Advanced to choose a version manually.";
746 return;
747 }
748
749 if (this.quickUpdateComparison <= 0) {
750 return;
751 }
752
753 await this.scheduleUpdateRequest(
754 {
755 branch: "main",
756 tag: this.mainBranchLatestTag,
757 backup_usr: this.info?.defaults?.backup_usr ?? true,
758 backup_path: this.info?.defaults?.backup_path || "",
759 backup_name: this.info?.defaults?.backup_name || "",
760 backup_conflict_policy:
761 this.info?.defaults?.backup_conflict_policy || "rename",
762 },
763 "Agent Zero is restarting to apply the latest version from main.",
764 );
765 },
766
767 async restartAndReload() {
768 this.restarting = true;
769 this.clearReconnectTimer();
770 let observedBackendUnavailable = false;
771 this.setRestartState(
772 "Starting self-update",
773 "The request was saved. Agent Zero is about to restart and apply the requested branch and version target."
774 );
775 this.ensureProgressOverlay();
776
777 let restartRequestStarted = false;
778 try {
779 const token = await API.getCsrfToken();
780 restartRequestStarted = true;
781 const restartResponse = await fetch("/api/restart", {
782 method: "POST",
783 credentials: "same-origin",
784 keepalive: true,
785 headers: {
786 "Content-Type": "application/json",
787 "X-CSRF-Token": token,
788 },
789 body: JSON.stringify({}),
790 });
791 if (restartResponse && !restartResponse.ok) {
792 if (restartResponse.status >= 500) {
793 console.warn(
794 `Restart request returned HTTP ${restartResponse.status} while Agent Zero was shutting down. Continuing to wait for the new runtime.`
795 );
796 this.setRestartState(
797 "Restarting backend",
798 "Agent Zero is shutting down and applying the update. Waiting for the new runtime to come back healthy."
799 );
800 } else {
801 throw new Error(
802 `Restart request failed with HTTP ${restartResponse.status}.`
803 );
804 }
805 } else {
806 this.setRestartState(
807 "Restarting backend",
808 "Agent Zero accepted the restart request. Waiting for the updater to take over."
809 );
810 }
811 } catch (error) {
812 if (!restartRequestStarted) {
813 this.restarting = false;
814 this.resetRestartState();
815 this.removeProgressOverlay();
816 throw error;
817 }
818 console.warn(
819 "Restart request connection closed while Agent Zero was restarting:",
820 error
821 );
822 }
823
824 const maxWaitMs =
825 ((this.info?.pending?.health_timeout_seconds ||
826 this.info?.defaults?.health_timeout_seconds ||
827 120) *
828 1000) +
829 HEALTH_WAIT_BUFFER_MS;
830 const deadline = Date.now() + maxWaitMs;
831 let lastError = "";
832 this.setRestartState(
833 "Update in progress",
834 "Agent Zero is restarting and the updater is running. This page will reload automatically when /api/health starts responding again."
835 );
836
837 while (Date.now() < deadline) {
838 try {
839 const response = await fetch("/api/health", {
840 method: "GET",
841 credentials: "same-origin",
842 cache: "no-store",
843 });
844 if (response.ok && observedBackendUnavailable) {
845 window.location.reload();
846 return;
847 }
848 if (response.ok) {
849 this.setRestartState(
850 "Restarting backend",
851 "Waiting for Agent Zero to disconnect before reloading the page."
852 );
853 lastError = "Health check is still responding before the restart has completed.";
854 } else {
855 observedBackendUnavailable = true;
856 this.setRestartState(
857 "Update in progress",
858 "Agent Zero is restarting and the updater is running. This page will reload automatically when the health check becomes healthy again."
859 );
860 lastError = `Health check returned HTTP ${response.status}.`;
861 }
862 } catch (error) {
863 observedBackendUnavailable = true;
864 this.setRestartState(
865 "Update in progress",
866 "Agent Zero is temporarily unavailable while it restarts. Waiting for the new runtime to become healthy."
867 );
868 lastError = error?.message || String(error);
869 }
870
871 await new Promise((resolve) => {
872 this._reconnectTimer = setTimeout(() => {
873 this._reconnectTimer = null;
874 resolve();
875 }, HEALTH_POLL_INTERVAL_MS);
876 });
877 }
878
879 this.restarting = false;
880 this.resetRestartState();
881 this.removeProgressOverlay();
882 this.error =
883 "Agent Zero did not come back within the expected window. It may still be rolling back. " +
884 (lastError ? `Last health check error: ${lastError}` : "");
885 await this.refresh();
886 },
887
888 close() {
889 closeModal(SELF_UPDATE_MODAL_PATH);
890 },
891 };
892
893 const store = createStore("selfUpdateStore", model);
894
895 export { store };