Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from tkinter import filedialog, messagebox

from deployer.config import DeployerConfig
from deployer.install_timing import InstallTiming
from deployer.logger import DeployerLogger
from deployer.webview_bridge import InstallationCancelled, run_web_installer
from deployer.windows_setup import (
Expand Down Expand Up @@ -888,6 +889,7 @@ def _append_log_line(self, line: str):

def _install_thread(self):
log = self.logger
timing = InstallTiming(log)
ws = WindowsSetup(self.config, log)

steps = [
Expand Down Expand Up @@ -923,31 +925,41 @@ def _install_thread(self):
message = f"Installation cancelled.{suffix}"
self.after(0, lambda text=message: self._finish_fail(text))
self._running = False
timing.finish("cancelled")
return

self._set_progress(pct, label)
started_at = timing.start_step()
try:
result = fn()
if result is not None and not result:
timing.record_step(label, started_at, "failed")
rollback_ok = ws.rollback_openclaw_upgrade()
suffix = "" if rollback_ok else " Automatic rollback also failed."
self._finish_fail(label.rstrip(".") + f" failed.{suffix}")
self._running = False
timing.finish("failed")
return
except InstallationCancelled:
timing.record_step(label, started_at, "cancelled")
self._running = False
self.after(0, self._return_to_welcome)
timing.finish("cancelled")
return
except Exception as e:
timing.record_step(label, started_at, "failed")
log.error(f"{label} exception: {e}")
rollback_ok = ws.rollback_openclaw_upgrade()
suffix = "" if rollback_ok else " Automatic rollback also failed."
detail = str(e).strip() or label.rstrip(".") + " failed."
self._finish_fail(f"{detail}{suffix}")
self._running = False
timing.finish("failed")
return
timing.record_step(label, started_at, "success")

self._running = False
timing.finish("success")
self._finish_ok()

def _prepare_upgrade(self, ws: WindowsSetup) -> bool:
Expand Down
47 changes: 47 additions & 0 deletions deployer/install_timing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Installation timing metrics shared by both installer frontends."""

from collections.abc import Callable
from dataclasses import dataclass
from time import perf_counter


@dataclass(frozen=True)
class StepTiming:
label: str
status: str
duration_ms: int


class InstallTiming:
"""Record readable per-step timings and a compact installation summary."""

def __init__(self, logger, clock: Callable[[], float] | None = None):
self._logger = logger
self._clock = clock or perf_counter
self._started_at = self._clock()
self._steps: list[StepTiming] = []
self._finished = False

def start_step(self) -> float:
return self._clock()

def record_step(self, label: str, started_at: float, status: str) -> None:
clean_label = label.rstrip(".")
duration_ms = max(0, round((self._clock() - started_at) * 1000))
self._steps.append(StepTiming(label=clean_label, status=status, duration_ms=duration_ms))
self._logger.info(f"Install timing: {clean_label} [{status}] {duration_ms / 1000:.2f}s")

def finish(self, status: str) -> None:
if self._finished:
return
self._finished = True

total_ms = max(0, round((self._clock() - self._started_at) * 1000))
self._logger.info(
f"Install timing summary: status={status} "
f"total={total_ms / 1000:.2f}s steps={len(self._steps)}"
)
if self._steps:
slowest = sorted(self._steps, key=lambda step: step.duration_ms, reverse=True)[:5]
details = "; ".join(f"{step.label}={step.duration_ms / 1000:.2f}s" for step in slowest)
self._logger.info(f"Slowest install steps: {details}")
123 changes: 108 additions & 15 deletions deployer/openclaw_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,28 @@ class UpgradePhase(StrEnum):
ROLLBACK_FAILED = "rollback-failed"


class UpgradeBackupMode(StrEnum):
FULL = "full"
MANAGED_STATE = "managed-state"


_MANAGED_STATE_PATHS = (
Path(".env"),
Path("openclaw.json"),
Path("skill_catalog.json"),
Path("managed_skill_catalog.json"),
Path("skills"),
Path("skills_snapshot.json"),
Path("skills_snapshot.sig"),
Path("skills_signing_key.pub"),
Path("skills_signing_key.pem"),
Path("MicroClawInstaller.exe"),
Path("_internal"),
Path("microclaw.ico"),
Path("microclaw-uninstall.ico"),
)


ACTIVE_PHASES = {
UpgradePhase.BACKING_UP,
UpgradePhase.INSTALLING,
Expand Down Expand Up @@ -75,6 +97,8 @@ class UpgradeManifest:
created_at: str
updated_at: str
validation_results: dict[str, bool] = field(default_factory=dict)
backup_mode: UpgradeBackupMode = UpgradeBackupMode.FULL
config_existed: bool = False


def _now() -> str:
Expand Down Expand Up @@ -557,6 +581,7 @@ def create(
state_dir: Path,
target_version: str,
installation: OpenClawInstallation,
backup_mode: UpgradeBackupMode = UpgradeBackupMode.FULL,
) -> OpenClawUpgradeTransaction:
root = microclaw_root.resolve(strict=False)
transaction_id = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + "-" + uuid.uuid4().hex[:8]
Expand Down Expand Up @@ -584,6 +609,8 @@ def create(
phase=UpgradePhase.BACKING_UP,
created_at=timestamp,
updated_at=timestamp,
backup_mode=backup_mode,
config_existed=(state_dir / "openclaw.json").exists(),
)
try:
transaction = cls(
Expand All @@ -605,6 +632,8 @@ def _read_manifest(cls, manifest_path: Path) -> UpgradeManifest:
if not isinstance(data, dict):
raise ValueError("manifest root must be an object")
data["phase"] = UpgradePhase(data["phase"])
if "backup_mode" in data:
data["backup_mode"] = UpgradeBackupMode(data["backup_mode"])
manifest = UpgradeManifest(**data)
except (KeyError, TypeError, json.JSONDecodeError) as error:
raise ValueError("invalid OpenClaw upgrade manifest") from error
Expand Down Expand Up @@ -663,6 +692,11 @@ def close(self) -> bool:
def _validate_manifest(self) -> None:
if self.manifest.schema_version != 1:
raise ValueError("unsupported OpenClaw upgrade manifest schema")
if self.manifest.backup_mode not in {
UpgradeBackupMode.FULL,
UpgradeBackupMode.MANAGED_STATE,
}:
raise ValueError("unsupported OpenClaw upgrade backup mode")
transaction_id = self.manifest.transaction_id
if not isinstance(transaction_id, str) or not _TRANSACTION_ID_PATTERN.fullmatch(
transaction_id
Expand Down Expand Up @@ -769,8 +803,55 @@ def report(done: int, total: int) -> None:

return report

def _publish_backup(self, staging: Path) -> None:
metadata_files = {"transaction.json", "backup-files.json"}
io_staging = _native_io_path(staging)
inventory = {}
for path in io_staging.rglob("*"):
relative = path.relative_to(io_staging).as_posix()
if path.is_file() and relative not in metadata_files:
inventory[relative] = path.stat().st_size
_atomic_json_write(staging / "backup-files.json", inventory)
_atomic_json_write(staging / "transaction.json", self._payload())
_fsync_directory(staging)
_durable_rename(staging, self.backup_dir)
self.set_phase(UpgradePhase.INSTALLING)

def _backup_managed_state(self) -> None:
state_dir = Path(self.manifest.state_dir)
if self.manifest.state_existed and not state_dir.exists():
raise FileNotFoundError(f"OpenClaw state disappeared before backup: {state_dir}")
config_path = state_dir / "openclaw.json"
if self.manifest.config_existed and not config_path.exists():
raise FileNotFoundError(f"OpenClaw config disappeared before backup: {config_path}")
if not self.manifest.config_existed and config_path.exists():
raise RuntimeError(f"OpenClaw config appeared before backup: {config_path}")

_durable_mkdir(self.backup_root)
staging = self.backup_dir.with_name(
f".{self.manifest.transaction_id}.{uuid.uuid4().hex}.staging"
)
_durable_mkdir(staging / "state")
for relative in _MANAGED_STATE_PATHS:
source = state_dir / relative
destination = staging / "state" / relative
if source.is_dir() and not source.is_symlink():
shutil.copytree(_native_io_path(source), _native_io_path(destination))
elif source.exists() or source.is_symlink():
_durable_mkdir(destination.parent)
shutil.copy2(_native_io_path(source), _native_io_path(destination))
_fsync_payload_tree(
staging / "state",
self._payload_progress("Backing up MicroClaw-managed state"),
)
self._publish_backup(staging)

def backup(self) -> None:
self.set_phase(UpgradePhase.BACKING_UP)
if self.manifest.backup_mode == UpgradeBackupMode.MANAGED_STATE:
self._backup_managed_state()
return

package_dir = Path(self.manifest.package_dir)
if self.manifest.package_existed and not package_dir.exists():
raise FileNotFoundError(f"OpenClaw package disappeared before backup: {package_dir}")
Expand Down Expand Up @@ -816,18 +897,7 @@ def backup(self) -> None:
):
_fsync_payload_tree(payload_root, self._payload_progress("Backing up OpenClaw files"))

metadata_files = {"transaction.json", "backup-files.json"}
io_staging = _native_io_path(staging)
inventory = {}
for path in io_staging.rglob("*"):
relative = path.relative_to(io_staging).as_posix()
if path.is_file() and relative not in metadata_files:
inventory[relative] = path.stat().st_size
_atomic_json_write(staging / "backup-files.json", inventory)
_atomic_json_write(staging / "transaction.json", self._payload())
_fsync_directory(staging)
_durable_rename(staging, self.backup_dir)
self.set_phase(UpgradePhase.INSTALLING)
self._publish_backup(staging)

def mark_verifying(self) -> None:
self.set_phase(UpgradePhase.VERIFYING)
Expand Down Expand Up @@ -927,6 +997,26 @@ def _restore_state(self, failed_dir: Path) -> None:
self.manifest.state_existed,
)

def _restore_managed_state(self, failed_dir: Path) -> None:
state_dir = Path(self.manifest.state_dir)
for relative in _MANAGED_STATE_PATHS:
live = state_dir / relative
backup = self.backup_dir / "state" / relative
failed = failed_dir / "state" / relative
if backup.is_dir() and not backup.is_symlink():
self._restore_tree(backup, live, failed, True)
continue

staging = None
if backup.exists() or backup.is_symlink():
_durable_mkdir(live.parent)
staging = live.with_name(f".{live.name}.{uuid.uuid4().hex}.restore")
shutil.copy2(_native_io_path(backup), _native_io_path(staging))
_fsync_file(staging)
self._move_to_failed(live, failed)
if staging is not None:
_durable_replace(staging, live)

def rollback(self) -> None:
original_phase = self.manifest.phase
if original_phase == UpgradePhase.ROLLED_BACK:
Expand All @@ -946,9 +1036,12 @@ def rollback(self) -> None:
if original_phase != UpgradePhase.BACKING_UP:
failed_dir = self.backup_dir / "failed"
_durable_mkdir(failed_dir)
self._restore_package(failed_dir)
self._restore_shims()
self._restore_state(failed_dir)
if self.manifest.backup_mode == UpgradeBackupMode.MANAGED_STATE:
self._restore_managed_state(failed_dir)
else:
self._restore_package(failed_dir)
self._restore_shims()
self._restore_state(failed_dir)
except Exception as error:
try:
self.mark_rollback_failed()
Expand Down
16 changes: 14 additions & 2 deletions deployer/webview_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from tkinter import filedialog

from deployer.config import DeployerConfig
from deployer.install_timing import InstallTiming
from deployer.logger import DeployerLogger
from deployer.windows_setup import (
DEFAULT_DESKTOP_DIR,
Expand Down Expand Up @@ -362,6 +363,7 @@ def _set_progress_detail(self, detail):
self._state["progress_detail"] = detail or ""

def _install_thread(self):
timing = InstallTiming(self._logger)
ws = WindowsSetup(self._config, self._logger)
ws.progress_callback = self._set_progress_detail

Expand Down Expand Up @@ -402,16 +404,24 @@ def _install_thread(self):
rollback_ok = ws.rollback_openclaw_upgrade()
suffix = "" if rollback_ok else " Automatic rollback also failed."
self._finish_fail(f"Installation cancelled.{suffix}")
timing.finish("cancelled")
return
self._set_progress(pct, label)
if not self._run_step_with_retry(pct, label, fn, retries):
if self.get_state()["status"] == "cancelled":
started_at = timing.start_step()
step_ok = self._run_step_with_retry(pct, label, fn, retries)
step_status = "success" if step_ok else self.get_state()["status"]
timing.record_step(label, started_at, step_status)
if not step_ok:
if step_status == "cancelled":
timing.finish("cancelled")
return
if not ws.rollback_openclaw_upgrade():
with self._state_lock:
self._state["error"] += " Automatic rollback also failed."
timing.finish("failed")
return

timing.finish("success")
self._finish_ok()

def _run_step_with_retry(self, pct, label, fn, retries):
Expand Down Expand Up @@ -489,6 +499,8 @@ def _confirm_close_running_apps(self, active: ActiveInstallation) -> bool:
process = f" (PID {', '.join(map(str, active.pids))})" if active.pids else ""
strings = _STRINGS[self._lang]
message = strings["runningAppsMessage"].format(process=process)
_ACTIVE_WINDOW.restore()
_ACTIVE_WINDOW.show()
return bool(_ACTIVE_WINDOW.create_confirmation_dialog(strings["runningAppsTitle"], message))

def _ensure_node(self, ws):
Expand Down
Loading
Loading