Download apps/bootstrap-installer/src-tauri/src/bootstrap.rs from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 42.7 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/apps/bootstrap-installer/src-tauri/src/bootstrap.rs
- Command line
-
hf download hf://SaylorTwift/hermes-agent/apps/bootstrap-installer/src-tauri/src/bootstrap.rs
-
curl -L -o bootstrap.rs https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/apps/bootstrap-installer/src-tauri/src/bootstrap.rs
42.7 kB
| //! Bootstrap orchestration. | |
| //! | |
| //! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.ts`. | |
| //! Drives install.ps1 / install.sh stage-by-stage, emits progress events | |
| //! over the Tauri `bootstrap` channel, writes a forensic log to | |
| //! HERMES_HOME/logs/bootstrap-<timestamp>.log. | |
| //! | |
| //! Lifecycle: | |
| //! 1. `start_bootstrap` (Tauri command) → spawns the worker task. | |
| //! 2. Worker resolves install script (dev/cache/download). | |
| //! 3. Worker calls `install.ps1 -Manifest` → emits `manifest` event. | |
| //! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`. | |
| //! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`. | |
| use std::path::{Path, PathBuf}; | |
| use std::sync::Arc; | |
| use std::time::{Instant, SystemTime, UNIX_EPOCH}; | |
| use anyhow::{anyhow, Context, Result}; | |
| use serde::{Deserialize, Serialize}; | |
| use tauri::{AppHandle, Emitter, State}; | |
| use tokio::sync::{mpsc, Mutex}; | |
| use crate::events::{BootstrapEvent, LogStream, Manifest, StageState}; | |
| use crate::install_script::{self, Pin, ScriptKind, ScriptSource}; | |
| use crate::powershell::{self, StreamSink}; | |
| use crate::AppState; | |
| const MAX_STAGE_ATTEMPTS: usize = 3; | |
| // --------------------------------------------------------------------------- | |
| // Public Tauri commands | |
| // --------------------------------------------------------------------------- | |
| /// Frontend → Rust: kick off the install. | |
| pub struct StartBootstrapArgs { | |
| /// Optional override for the commit pin. Defaults to the build-time | |
| /// pin baked in via `BUILD_PIN_COMMIT`. | |
| pub commit: Option<String>, | |
| /// Optional override for the branch pin. Defaults to `BUILD_PIN_BRANCH`. | |
| pub branch: Option<String>, | |
| /// Include Stage-Desktop (build apps/desktop) in the manifest. The | |
| /// signed bootstrap installer passes true; the deprecated Electron-side | |
| /// bootstrap-runner passes false to avoid building-while-running. | |
| pub include_desktop: bool, | |
| /// Optional override for HERMES_HOME. Tests use this; production | |
| /// almost always falls back to the OS default. | |
| pub hermes_home: Option<String>, | |
| } | |
| fn default_true() -> bool { | |
| true | |
| } | |
| pub struct BootstrapStatus { | |
| pub running: bool, | |
| pub completed: bool, | |
| pub install_root: Option<String>, | |
| pub last_error: Option<String>, | |
| } | |
| /// Handle stored in AppState while a bootstrap run is in flight. Carries | |
| /// the cancellation channel and the most recent terminal status so the | |
| /// frontend can re-query after a window refresh. | |
| pub struct BootstrapHandle { | |
| pub cancel_tx: mpsc::Sender<()>, | |
| pub started_at: Instant, | |
| pub status: BootstrapStatus, | |
| } | |
| pub async fn start_bootstrap( | |
| app: AppHandle, | |
| state: State<'_, Arc<AppState>>, | |
| args: StartBootstrapArgs, | |
| ) -> Result<(), String> { | |
| let mut guard = state.bootstrap.lock().await; | |
| if let Some(h) = guard.as_ref() { | |
| if h.status.running { | |
| return Err("Bootstrap is already running".into()); | |
| } | |
| } | |
| let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1); | |
| let handle = BootstrapHandle { | |
| cancel_tx, | |
| started_at: Instant::now(), | |
| status: BootstrapStatus { | |
| running: true, | |
| completed: false, | |
| install_root: None, | |
| last_error: None, | |
| }, | |
| }; | |
| *guard = Some(handle); | |
| drop(guard); | |
| let app_for_task = app.clone(); | |
| let state_for_task = state.inner().clone(); | |
| let args_for_task = args; | |
| let cancel_rx = Arc::new(Mutex::new(Some(cancel_rx))); | |
| tokio::spawn(async move { | |
| let result = run_bootstrap(app_for_task.clone(), args_for_task, cancel_rx).await; | |
| // Reflect terminal state into AppState so get_bootstrap_status() | |
| // can serve it after the task exits. | |
| let mut guard = state_for_task.bootstrap.lock().await; | |
| if let Some(h) = guard.as_mut() { | |
| h.status.running = false; | |
| match &result { | |
| Ok(install_root) => { | |
| h.status.completed = true; | |
| h.status.install_root = Some(install_root.clone()); | |
| h.status.last_error = None; | |
| } | |
| Err(err) => { | |
| h.status.completed = false; | |
| h.status.last_error = Some(err.to_string()); | |
| } | |
| } | |
| } | |
| }); | |
| Ok(()) | |
| } | |
| pub async fn cancel_bootstrap(state: State<'_, Arc<AppState>>) -> Result<(), String> { | |
| let guard = state.bootstrap.lock().await; | |
| if let Some(h) = guard.as_ref() { | |
| let _ = h.cancel_tx.try_send(()); | |
| } | |
| Ok(()) | |
| } | |
| pub async fn get_bootstrap_status( | |
| state: State<'_, Arc<AppState>>, | |
| ) -> Result<BootstrapStatus, String> { | |
| let guard = state.bootstrap.lock().await; | |
| Ok(match guard.as_ref() { | |
| Some(h) => BootstrapStatus { | |
| running: h.status.running, | |
| completed: h.status.completed, | |
| install_root: h.status.install_root.clone(), | |
| last_error: h.status.last_error.clone(), | |
| }, | |
| None => BootstrapStatus { | |
| running: false, | |
| completed: false, | |
| install_root: None, | |
| last_error: None, | |
| }, | |
| }) | |
| } | |
| /// Spawn the locally-built Hermes desktop binary, then close the installer | |
| /// window. Caller resolves the binary path from `install_root`. | |
| /// | |
| /// Returns Err with a human-readable message if the binary doesn't exist | |
| /// (e.g. when Stage-Desktop was skipped) so the frontend can present | |
| /// actionable failure UI rather than silently doing nothing. | |
| pub async fn launch_hermes_desktop( | |
| app: AppHandle, | |
| install_root: String, | |
| ) -> Result<(), String> { | |
| let install_root = PathBuf::from(install_root); | |
| let exe_path = resolve_hermes_desktop_exe(&install_root).ok_or_else(|| { | |
| format!( | |
| "Couldn't find a built Hermes desktop at {}. The desktop build step \ | |
| may have been skipped or failed. Run `hermes desktop` from a \ | |
| terminal to build and launch it.", | |
| install_root.join("apps").join("desktop").join("release").display() | |
| ) | |
| })?; | |
| tracing::info!(?exe_path, "launching Hermes desktop"); | |
| // Detach from us — the installer is about to exit. On macOS launch the | |
| // bundle through LaunchServices instead of exec'ing Contents/MacOS/Hermes | |
| // directly; this matches user double-click/open behavior and avoids cwd / | |
| // quarantine oddities after a self-update rebuild. | |
| let mut cmd = desktop_launch_command(&exe_path, &install_root); | |
| { | |
| use std::os::windows::process::CommandExt; | |
| // DETACHED_PROCESS = 0x00000008 | |
| cmd.creation_flags(0x0000_0008); | |
| } | |
| cmd.spawn().map_err(|e| { | |
| format!( | |
| "failed to launch {}: {e}", | |
| exe_path.display() | |
| ) | |
| })?; | |
| // Give Windows ~150ms to actually start the new process before we exit. | |
| tokio::time::sleep(std::time::Duration::from_millis(150)).await; | |
| // Exit the installer cleanly. Tauri's process plugin gives us the | |
| // right hook regardless of platform. | |
| app.exit(0); | |
| Ok(()) | |
| } | |
| /// Walks the well-known electron-builder unpacked-app paths under | |
| /// `install_root`. Mirrors the resolver in `cmd_gui` (apps/desktop/release/ | |
| /// <os>-unpacked/<exe>). | |
| pub(crate) fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> { | |
| let release_dir = install_root.join("apps").join("desktop").join("release"); | |
| let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") { | |
| &[ | |
| ("win-unpacked", "Hermes.exe"), | |
| ("win-arm64-unpacked", "Hermes.exe"), | |
| ] | |
| } else if cfg!(target_os = "macos") { | |
| &[ | |
| ("mac/Hermes.app/Contents/MacOS", "Hermes"), | |
| ("mac-arm64/Hermes.app/Contents/MacOS", "Hermes"), | |
| ] | |
| } else { | |
| &[("linux-unpacked", "hermes")] | |
| }; | |
| for (subdir, exe) in candidates { | |
| let p = release_dir.join(subdir).join(exe); | |
| if p.exists() { | |
| return Some(p); | |
| } | |
| } | |
| None | |
| } | |
| pub(crate) fn resolve_hermes_desktop_app(install_root: &std::path::Path) -> Option<PathBuf> { | |
| let exe = resolve_hermes_desktop_exe(install_root)?; | |
| { | |
| // .../Hermes.app/Contents/MacOS/Hermes -> .../Hermes.app | |
| let app = exe.parent()?.parent()?.parent()?.to_path_buf(); | |
| if app.extension().and_then(|e| e.to_str()) == Some("app") && app.is_dir() { | |
| return Some(app); | |
| } | |
| } | |
| { | |
| return Some(exe); | |
| } | |
| None | |
| } | |
| /// True when a prior install completed (bootstrap-complete marker present) AND a | |
| /// launchable desktop app exists on disk. Used by the installer's launcher fast | |
| /// path so a bare re-open just opens Hermes instead of re-running setup. | |
| pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool { | |
| install_root.join(".hermes-bootstrap-complete").exists() | |
| && resolve_hermes_desktop_exe(install_root).is_some() | |
| } | |
| fn resolve_marker_commit(install_root: &Path, pin: &Pin) -> Option<String> { | |
| if let Some(commit) = pin | |
| .commit | |
| .as_ref() | |
| .filter(|commit| !commit.trim().is_empty()) | |
| { | |
| return Some(commit.clone()); | |
| } | |
| let output = std::process::Command::new("git") | |
| .args(["rev-parse", "HEAD"]) | |
| .current_dir(install_root) | |
| .output() | |
| .ok()?; | |
| if !output.status.success() { | |
| return None; | |
| } | |
| let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); | |
| if commit.is_empty() { | |
| None | |
| } else { | |
| Some(commit) | |
| } | |
| } | |
| fn write_bootstrap_complete_marker(install_root: &Path, pin: &Pin) -> Result<serde_json::Value> { | |
| use std::io::Write; | |
| let marker_path = crate::paths::likely_bootstrap_marker(install_root); | |
| if let Some(parent) = marker_path.parent() { | |
| std::fs::create_dir_all(parent).with_context(|| { | |
| format!( | |
| "could not create bootstrap marker directory {}", | |
| parent.display() | |
| ) | |
| })?; | |
| } | |
| let completed_at_unix = SystemTime::now() | |
| .duration_since(UNIX_EPOCH) | |
| .map(|duration| duration.as_secs()) | |
| .unwrap_or_default(); | |
| let marker = serde_json::json!({ | |
| "schemaVersion": 1, | |
| "pinnedCommit": resolve_marker_commit(install_root, pin), | |
| "pinnedBranch": pin.branch.clone(), | |
| "completedAtUnix": completed_at_unix, | |
| }); | |
| let mut body = serde_json::to_vec_pretty(&marker)?; | |
| body.push(b'\n'); | |
| // Atomic publish (temp sibling + flush + rename), matching Electron's | |
| // writeFileAtomic(). hermes_is_installed() only checks existence, so a | |
| // partial direct write would incorrectly enable the launcher fast path. | |
| let tmp_path = install_root.join(".hermes-bootstrap-complete.tmp"); | |
| { | |
| let mut file = std::fs::File::create(&tmp_path).with_context(|| { | |
| format!( | |
| "could not create temp bootstrap marker {}", | |
| tmp_path.display() | |
| ) | |
| })?; | |
| file.write_all(&body).with_context(|| { | |
| format!( | |
| "could not write temp bootstrap marker {}", | |
| tmp_path.display() | |
| ) | |
| })?; | |
| file.sync_all().with_context(|| { | |
| format!( | |
| "could not flush temp bootstrap marker {}", | |
| tmp_path.display() | |
| ) | |
| })?; | |
| } | |
| // Windows rename fails if the destination already exists; drop any prior | |
| // marker first so a re-run can still publish a fresh payload. | |
| if marker_path.exists() { | |
| std::fs::remove_file(&marker_path).with_context(|| { | |
| format!( | |
| "could not replace existing bootstrap marker {}", | |
| marker_path.display() | |
| ) | |
| })?; | |
| } | |
| if let Err(err) = std::fs::rename(&tmp_path, &marker_path) { | |
| let _ = std::fs::remove_file(&tmp_path); | |
| return Err(err).with_context(|| { | |
| format!( | |
| "could not publish bootstrap marker {} → {}", | |
| tmp_path.display(), | |
| marker_path.display() | |
| ) | |
| }); | |
| } | |
| tracing::info!(path = %marker_path.display(), "bootstrap marker written"); | |
| Ok(marker) | |
| } | |
| /// Spawn the already-built desktop app, detached. Returns Err if no built app | |
| /// exists or the spawn fails, so the caller can fall back to showing the | |
| /// installer UI. | |
| pub(crate) fn spawn_installed_desktop(install_root: &std::path::Path) -> std::io::Result<()> { | |
| let exe = resolve_hermes_desktop_exe(install_root).ok_or_else(|| { | |
| std::io::Error::new(std::io::ErrorKind::NotFound, "no built Hermes desktop app") | |
| })?; | |
| let mut cmd = desktop_launch_command_std(&exe, install_root); | |
| { | |
| use std::os::windows::process::CommandExt; | |
| // DETACHED_PROCESS = 0x00000008 — keep the desktop alive after the | |
| // installer exits, mirroring launch_hermes_desktop. Kept correct here | |
| // even though the only caller is macOS-gated today, so future reuse on | |
| // Windows doesn't reintroduce the relaunch race. | |
| cmd.creation_flags(0x0000_0008); | |
| } | |
| cmd.spawn().map(|_child| ()) | |
| } | |
| pub(crate) fn open_macos_app_detached(app_bundle: &std::path::Path) -> std::io::Result<()> { | |
| let mut cmd = std::process::Command::new("/usr/bin/open"); | |
| cmd.arg(app_bundle); | |
| cmd.current_dir(crate::paths::hermes_home()); | |
| cmd.spawn().map(|_child| ()) | |
| } | |
| fn app_bundle_for_exe(exe: &std::path::Path) -> Option<PathBuf> { | |
| let app = exe.parent()?.parent()?.parent()?.to_path_buf(); | |
| if app.extension().and_then(|e| e.to_str()) == Some("app") && app.is_dir() { | |
| Some(app) | |
| } else { | |
| None | |
| } | |
| } | |
| fn desktop_launch_command( | |
| exe_path: &std::path::Path, | |
| install_root: &std::path::Path, | |
| ) -> tokio::process::Command { | |
| { | |
| if let Some(app_bundle) = app_bundle_for_exe(exe_path) { | |
| let mut cmd = tokio::process::Command::new("/usr/bin/open"); | |
| cmd.arg(app_bundle); | |
| cmd.current_dir(crate::paths::hermes_home()); | |
| return cmd; | |
| } | |
| } | |
| let mut cmd = tokio::process::Command::new(exe_path); | |
| cmd.current_dir(exe_path.parent().unwrap_or(install_root)); | |
| cmd | |
| } | |
| fn desktop_launch_command_std( | |
| exe_path: &std::path::Path, | |
| install_root: &std::path::Path, | |
| ) -> std::process::Command { | |
| { | |
| if let Some(app_bundle) = app_bundle_for_exe(exe_path) { | |
| let mut cmd = std::process::Command::new("/usr/bin/open"); | |
| cmd.arg(app_bundle); | |
| cmd.current_dir(crate::paths::hermes_home()); | |
| return cmd; | |
| } | |
| } | |
| let mut cmd = std::process::Command::new(exe_path); | |
| cmd.current_dir(exe_path.parent().unwrap_or(install_root)); | |
| cmd | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Bootstrap implementation | |
| // --------------------------------------------------------------------------- | |
| async fn run_bootstrap( | |
| app: AppHandle, | |
| args: StartBootstrapArgs, | |
| cancel_rx_holder: Arc<Mutex<Option<mpsc::Receiver<()>>>>, | |
| ) -> Result<String> { | |
| let kind = ScriptKind::for_current_os(); | |
| let pin = Pin { | |
| commit: args.commit.or_else(|| option_env_string("BUILD_PIN_COMMIT")), | |
| branch: args.branch.or_else(|| option_env_string("BUILD_PIN_BRANCH")), | |
| }; | |
| tracing::info!( | |
| ?pin, | |
| kind = ?kind, | |
| include_desktop = args.include_desktop, | |
| "bootstrap starting" | |
| ); | |
| let app_for_log = app.clone(); | |
| let emit_log = move |line: &str| { | |
| emit_event( | |
| &app_for_log, | |
| BootstrapEvent::Log { | |
| stage: None, | |
| line: line.to_string(), | |
| stream: LogStream::Stdout, | |
| }, | |
| ); | |
| // Bump to info-level so the line shows in bootstrap-installer.log | |
| // under the default INFO filter. Previously this was debug! which | |
| // got dropped on the floor, leaving us blind whenever install.ps1 | |
| // failed — the log only had the "bootstrap starting" banner. | |
| tracing::info!(target: "bootstrap.log", "{line}"); | |
| }; | |
| // 1. Resolve install.ps1 | |
| let script = install_script::resolve(kind, &pin, &emit_log) | |
| .await | |
| .map_err(|e| { | |
| let msg = format!("resolve install script failed: {e:#}"); | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Failed { | |
| stage: None, | |
| error: msg.clone(), | |
| }, | |
| ); | |
| anyhow!(msg) | |
| })?; | |
| let source_note = match &script.source { | |
| ScriptSource::DevCheckout => "dev checkout", | |
| ScriptSource::Bundled => "bundled", | |
| ScriptSource::Cached => "cached", | |
| ScriptSource::Downloaded => "downloaded", | |
| }; | |
| emit_log(&format!( | |
| "[bootstrap] script {} via {}", | |
| script.path.display(), | |
| source_note | |
| )); | |
| // 2. Fetch manifest | |
| // | |
| // -IncludeDesktop MUST be passed to the manifest call too — install.ps1 | |
| // gates the desktop stage inclusion on this flag, so without it here | |
| // the manifest comes back missing the desktop stage and we never run | |
| // it. The per-stage call below also passes -IncludeDesktop to keep | |
| // the contracts identical. | |
| let manifest_args = build_pin_args(&script); | |
| let mut manifest_args_full = vec!["-Manifest".to_string()]; | |
| manifest_args_full.extend(manifest_args.clone()); | |
| if args.include_desktop { | |
| manifest_args_full.push("-IncludeDesktop".to_string()); | |
| } | |
| let mut manifest_cancel_rx = None; | |
| let manifest_result = run_install_script( | |
| &app, | |
| &script.path, | |
| &manifest_args_full, | |
| args.hermes_home.as_deref(), | |
| &mut manifest_cancel_rx, | |
| Some("__manifest__".to_string()), | |
| ) | |
| .await?; | |
| if manifest_result.exit_code != Some(0) { | |
| let err = format!( | |
| "install.ps1 -Manifest failed: exit {:?}\n{}", | |
| manifest_result.exit_code, | |
| manifest_result.stderr.trim() | |
| ); | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Failed { | |
| stage: None, | |
| error: err.clone(), | |
| }, | |
| ); | |
| return Err(anyhow!(err)); | |
| } | |
| let manifest: Manifest = powershell::parse_manifest(&manifest_result.stdout).ok_or_else(|| { | |
| let err = format!( | |
| "install.ps1 -Manifest produced no parseable JSON payload\n{}", | |
| truncate(&manifest_result.stdout, 4000) | |
| ); | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Failed { | |
| stage: None, | |
| error: err.clone(), | |
| }, | |
| ); | |
| anyhow!(err) | |
| })?; | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Manifest { | |
| stages: manifest.stages.clone(), | |
| protocol_version: manifest.protocol_version, | |
| }, | |
| ); | |
| // 3. Iterate stages. | |
| for stage in &manifest.stages { | |
| // Skip Stage-Desktop unless explicitly requested. install.ps1 may | |
| // or may not include it in the manifest depending on the flag we | |
| // pass, but if it slipped in, gate client-side too. | |
| if !args.include_desktop && stage.name.eq_ignore_ascii_case("desktop") { | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Stage { | |
| name: stage.name.clone(), | |
| state: StageState::Skipped, | |
| duration_ms: Some(0), | |
| result: None, | |
| error: Some("skipped by include_desktop=false".into()), | |
| }, | |
| ); | |
| continue; | |
| } | |
| if cancellation_signalled(&cancel_rx_holder).await { | |
| let err = "bootstrap cancelled by user".to_string(); | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Failed { | |
| stage: Some(stage.name.clone()), | |
| error: err.clone(), | |
| }, | |
| ); | |
| return Err(anyhow!(err)); | |
| } | |
| let started = Instant::now(); | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Stage { | |
| name: stage.name.clone(), | |
| state: StageState::Running, | |
| duration_ms: None, | |
| result: None, | |
| error: None, | |
| }, | |
| ); | |
| let mut stage_args = vec![ | |
| "-Stage".to_string(), | |
| stage.name.clone(), | |
| "-NonInteractive".to_string(), | |
| "-Json".to_string(), | |
| ]; | |
| stage_args.extend(manifest_args.clone()); | |
| if args.include_desktop { | |
| stage_args.push("-IncludeDesktop".to_string()); | |
| } | |
| // A Windows PowerShell host can occasionally terminate with raw status | |
| // 0xffffffff while a long-running native child (npm / Playwright) is | |
| // still active. That bypasses install.ps1's finally block, so there is | |
| // no JSON frame to distinguish success from failure. Stage workers are | |
| // required to be idempotent; retry only this exact abrupt-host shape, | |
| // and keep it bounded so ordinary script failures remain immediate. | |
| let mut attempt = 1; | |
| let mut local_cancel_rx = cancel_rx_holder.lock().await.take(); | |
| let (stage_result, result_frame) = loop { | |
| let mut result = run_install_script( | |
| &app, | |
| &script.path, | |
| &stage_args, | |
| args.hermes_home.as_deref(), | |
| &mut local_cancel_rx, | |
| Some(stage.name.clone()), | |
| ) | |
| .await?; | |
| let frame = powershell::parse_stage_result(&result.stdout); | |
| if should_retry_missing_stage_frame(result.exit_code, result.killed, attempt) | |
| && frame.is_none() | |
| { | |
| if retry_backoff_cancelled(local_cancel_rx.as_mut()).await { | |
| result.killed = true; | |
| break (result, frame); | |
| } | |
| attempt += 1; | |
| let line = format!( | |
| "[bootstrap] {} stage host exited unexpectedly before its JSON result; retrying ({attempt}/{MAX_STAGE_ATTEMPTS})", | |
| stage.name | |
| ); | |
| tracing::warn!( | |
| stage = %stage.name, | |
| exit = ?result.exit_code, | |
| attempt, | |
| "stage host exited without a result frame; retrying" | |
| ); | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Log { | |
| stage: Some(stage.name.clone()), | |
| line, | |
| stream: LogStream::Stderr, | |
| }, | |
| ); | |
| continue; | |
| } | |
| break (result, frame); | |
| }; | |
| *cancel_rx_holder.lock().await = local_cancel_rx; | |
| let duration_ms = started.elapsed().as_millis() as u64; | |
| if stage_result.killed { | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Stage { | |
| name: stage.name.clone(), | |
| state: StageState::Failed, | |
| duration_ms: Some(duration_ms), | |
| result: None, | |
| error: Some("cancelled by user".into()), | |
| }, | |
| ); | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Failed { | |
| stage: Some(stage.name.clone()), | |
| error: "cancelled by user".into(), | |
| }, | |
| ); | |
| return Err(anyhow!("cancelled by user")); | |
| } | |
| match result_frame { | |
| None => { | |
| let err = format!( | |
| "install.ps1 -Stage {} produced no JSON result frame (exit={:?})", | |
| stage.name, stage_result.exit_code | |
| ); | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Stage { | |
| name: stage.name.clone(), | |
| state: StageState::Failed, | |
| duration_ms: Some(duration_ms), | |
| result: None, | |
| error: Some(err.clone()), | |
| }, | |
| ); | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Failed { | |
| stage: Some(stage.name.clone()), | |
| error: err.clone(), | |
| }, | |
| ); | |
| return Err(anyhow!(err)); | |
| } | |
| Some(frame) if frame.ok && frame.skipped => { | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Stage { | |
| name: stage.name.clone(), | |
| state: StageState::Skipped, | |
| duration_ms: Some(duration_ms), | |
| result: Some(frame), | |
| error: None, | |
| }, | |
| ); | |
| } | |
| Some(frame) if frame.ok => { | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Stage { | |
| name: stage.name.clone(), | |
| state: StageState::Succeeded, | |
| duration_ms: Some(duration_ms), | |
| result: Some(frame), | |
| error: None, | |
| }, | |
| ); | |
| } | |
| Some(frame) => { | |
| let err = frame | |
| .reason | |
| .clone() | |
| .unwrap_or_else(|| format!("exit code {:?}", stage_result.exit_code)); | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Stage { | |
| name: stage.name.clone(), | |
| state: StageState::Failed, | |
| duration_ms: Some(duration_ms), | |
| result: Some(frame), | |
| error: Some(err.clone()), | |
| }, | |
| ); | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Failed { | |
| stage: Some(stage.name.clone()), | |
| error: err.clone(), | |
| }, | |
| ); | |
| return Err(anyhow!(err)); | |
| } | |
| } | |
| } | |
| // 4. Resolve install_root. install.ps1 doesn't (yet) report this back | |
| // explicitly; we infer it from $HermesHome which Stage-Repository clones | |
| // the repo INTO at $HermesHome\hermes-agent. Mirrors hermes_constants. | |
| let hermes_home = args | |
| .hermes_home | |
| .clone() | |
| .unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned()); | |
| let install_root = PathBuf::from(&hermes_home).join("hermes-agent"); | |
| // Marker publish is terminal for this run: a write failure must emit Failed | |
| // so the UI leaves the progress state (it does not poll get_bootstrap_status). | |
| let marker = match write_bootstrap_complete_marker(&install_root, &pin) { | |
| Ok(marker) => marker, | |
| Err(err) => { | |
| let msg = format!("write bootstrap marker failed: {err:#}"); | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Failed { | |
| stage: None, | |
| error: msg.clone(), | |
| }, | |
| ); | |
| return Err(anyhow!(msg)); | |
| } | |
| }; | |
| // Copy ourselves to HERMES_HOME/hermes-setup.exe so the desktop app can | |
| // re-invoke us with `--update` and shortcuts have a stable target. This is | |
| // a one-shot install concern; an `--update` re-invocation no-ops because | |
| // we're already running from that path. Best-effort — a failure here must | |
| // not fail an otherwise-successful install. | |
| if let Err(err) = crate::paths::copy_self_to_hermes_home() { | |
| tracing::warn!(?err, "failed to copy installer into HERMES_HOME (non-fatal)"); | |
| emit_log(&format!( | |
| "[bootstrap] warning: could not stage updater binary: {err}" | |
| )); | |
| } | |
| emit_event( | |
| &app, | |
| BootstrapEvent::Complete { | |
| install_root: install_root.to_string_lossy().into_owned(), | |
| marker: Some(marker), | |
| }, | |
| ); | |
| Ok(install_root.to_string_lossy().into_owned()) | |
| } | |
| fn should_retry_missing_stage_frame( | |
| exit_code: Option<i32>, | |
| killed: bool, | |
| attempt: usize, | |
| ) -> bool { | |
| !killed && exit_code == Some(-1) && attempt < MAX_STAGE_ATTEMPTS | |
| } | |
| async fn retry_backoff_cancelled(cancel_rx: Option<&mut mpsc::Receiver<()>>) -> bool { | |
| let backoff = tokio::time::sleep(std::time::Duration::from_millis(500)); | |
| tokio::pin!(backoff); | |
| match cancel_rx { | |
| Some(rx) => tokio::select! { | |
| biased; | |
| signal = rx.recv() => signal.is_some(), | |
| _ = &mut backoff => false, | |
| }, | |
| None => { | |
| backoff.await; | |
| false | |
| } | |
| } | |
| } | |
| async fn cancellation_signalled(holder: &Arc<Mutex<Option<mpsc::Receiver<()>>>>) -> bool { | |
| let mut guard = holder.lock().await; | |
| if let Some(rx) = guard.as_mut() { | |
| rx.try_recv().is_ok() | |
| } else { | |
| false | |
| } | |
| } | |
| async fn run_install_script( | |
| app: &AppHandle, | |
| script_path: &std::path::Path, | |
| args: &[String], | |
| hermes_home_override: Option<&str>, | |
| cancel_rx: &mut Option<mpsc::Receiver<()>>, | |
| stage_name: Option<String>, | |
| ) -> Result<powershell::ScriptResult> { | |
| let app_for_stdout = app.clone(); | |
| let stage_for_stdout = stage_name.clone(); | |
| let app_for_stderr = app.clone(); | |
| let stage_for_stderr = stage_name.clone(); | |
| let stage_for_stdout_log = stage_name.clone(); | |
| let stage_for_stderr_log = stage_name.clone(); | |
| let sink = StreamSink { | |
| on_stdout_line: Box::new(move |line: &str| { | |
| emit_event( | |
| &app_for_stdout, | |
| BootstrapEvent::Log { | |
| stage: stage_for_stdout.clone(), | |
| line: line.to_string(), | |
| stream: LogStream::Stdout, | |
| }, | |
| ); | |
| // Tee to the rolling installer log so we have a persistent | |
| // record of every install.ps1 line. Without this, the only | |
| // log evidence of a failure was the Tauri event stream — | |
| // which gets discarded the moment the failure route mounts. | |
| match &stage_for_stdout_log { | |
| Some(name) => { | |
| tracing::info!(target: "bootstrap.log", stage = %name, "{line}") | |
| } | |
| None => tracing::info!(target: "bootstrap.log", "{line}"), | |
| } | |
| }), | |
| on_stderr_line: Box::new(move |line: &str| { | |
| emit_event( | |
| &app_for_stderr, | |
| BootstrapEvent::Log { | |
| stage: stage_for_stderr.clone(), | |
| line: line.to_string(), | |
| stream: LogStream::Stderr, | |
| }, | |
| ); | |
| // stderr-level lines get warn! so they're visually distinct | |
| // when scrolling through the log later. | |
| match &stage_for_stderr_log { | |
| Some(name) => { | |
| tracing::warn!(target: "bootstrap.log", stage = %name, "stderr: {line}") | |
| } | |
| None => tracing::warn!(target: "bootstrap.log", "stderr: {line}"), | |
| } | |
| }), | |
| }; | |
| powershell::run_script(script_path, args, sink, hermes_home_override, cancel_rx) | |
| .await | |
| .map_err(|e| { | |
| tracing::error!(?e, "install script invocation failed"); | |
| anyhow!("install script invocation failed: {e:#}") | |
| }) | |
| } | |
| fn build_pin_args(script: &install_script::ResolvedScript) -> Vec<String> { | |
| let mut out = Vec::new(); | |
| if let Some(c) = &script.commit { | |
| out.push("-Commit".to_string()); | |
| out.push(c.clone()); | |
| } | |
| if let Some(b) = &script.branch { | |
| out.push("-Branch".to_string()); | |
| out.push(b.clone()); | |
| } | |
| out | |
| } | |
| fn emit_event(app: &AppHandle, event: BootstrapEvent) { | |
| // Tee important state transitions to the rolling installer log so | |
| // bootstrap-installer.log isn't just "starting" + final summary. | |
| // Log lines (the noisy stuff) handle their own tracing in | |
| // run_install_script's sink; here we cover the lifecycle frames. | |
| match &event { | |
| BootstrapEvent::Manifest { stages, .. } => { | |
| tracing::info!( | |
| stage_count = stages.len(), | |
| names = ?stages.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), | |
| "manifest received" | |
| ); | |
| } | |
| BootstrapEvent::Stage { | |
| name, | |
| state, | |
| duration_ms, | |
| error, | |
| .. | |
| } => { | |
| tracing::info!( | |
| stage = %name, | |
| ?state, | |
| duration_ms = ?duration_ms, | |
| error = ?error, | |
| "stage transition" | |
| ); | |
| } | |
| BootstrapEvent::Complete { install_root, .. } => { | |
| tracing::info!(install_root = %install_root, "bootstrap complete"); | |
| } | |
| BootstrapEvent::Failed { stage, error } => { | |
| tracing::error!(stage = ?stage, error = %error, "bootstrap FAILED"); | |
| } | |
| BootstrapEvent::Log { .. } => { | |
| // Log lines are teed via the sink callbacks in | |
| // run_install_script — don't double-emit here. | |
| } | |
| } | |
| if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) { | |
| tracing::warn!(?e, "failed to emit bootstrap event"); | |
| } | |
| } | |
| fn option_env_string(key: &str) -> Option<String> { | |
| // option_env! only accepts literals, so we hardcode the known keys. | |
| let val = match key { | |
| "BUILD_PIN_COMMIT" => option_env!("BUILD_PIN_COMMIT"), | |
| "BUILD_PIN_BRANCH" => option_env!("BUILD_PIN_BRANCH"), | |
| _ => None, | |
| }; | |
| val.map(|s| s.to_string()) | |
| } | |
| fn truncate(s: &str, max: usize) -> String { | |
| if s.len() <= max { | |
| s.to_string() | |
| } else { | |
| format!("{}...", &s[..max]) | |
| } | |
| } | |
| mod tests { | |
| use super::*; | |
| use std::path::PathBuf; | |
| use std::path::Path; | |
| fn unique_tmp_dir(tag: &str) -> PathBuf { | |
| let base = std::env::temp_dir().join(format!( | |
| "hermes-bootstrap-test-{tag}-{}-{}", | |
| std::process::id(), | |
| std::time::SystemTime::now() | |
| .duration_since(std::time::UNIX_EPOCH) | |
| .unwrap() | |
| .as_nanos() | |
| )); | |
| std::fs::create_dir_all(&base).unwrap(); | |
| base | |
| } | |
| // Build a fake built-desktop release tree at the platform's expected path | |
| // and return (install_root, expected_app_bundle_or_exe). | |
| fn make_release_tree(install_root: &Path) -> PathBuf { | |
| let release = install_root.join("apps").join("desktop").join("release"); | |
| if cfg!(target_os = "macos") { | |
| let macos_dir = release | |
| .join("mac-arm64") | |
| .join("Hermes.app") | |
| .join("Contents") | |
| .join("MacOS"); | |
| std::fs::create_dir_all(&macos_dir).unwrap(); | |
| std::fs::write(macos_dir.join("Hermes"), b"#!/bin/sh\n").unwrap(); | |
| macos_dir.parent().unwrap().parent().unwrap().to_path_buf() // .../Hermes.app | |
| } else if cfg!(target_os = "windows") { | |
| let dir = release.join("win-unpacked"); | |
| std::fs::create_dir_all(&dir).unwrap(); | |
| let exe = dir.join("Hermes.exe"); | |
| std::fs::write(&exe, b"stub").unwrap(); | |
| exe | |
| } else { | |
| let dir = release.join("linux-unpacked"); | |
| std::fs::create_dir_all(&dir).unwrap(); | |
| let exe = dir.join("hermes"); | |
| std::fs::write(&exe, b"stub").unwrap(); | |
| exe | |
| } | |
| } | |
| // The relaunch / install target is derived from the rebuilt desktop app. | |
| // On macOS this MUST resolve to the .app bundle (what `open` relaunches and | |
| // what the updater ditto's over /Applications/Hermes.app). A regression in | |
| // this derivation breaks the post-update auto-relaunch, so guard it. | |
| fn resolve_hermes_desktop_app_finds_built_bundle() { | |
| let root = unique_tmp_dir("app-ok"); | |
| let expected = make_release_tree(&root); | |
| let resolved = resolve_hermes_desktop_app(&root) | |
| .expect("should resolve the freshly-built desktop app"); | |
| { | |
| assert_eq!(resolved, expected, "must resolve to the .app bundle"); | |
| assert_eq!( | |
| resolved.extension().and_then(|e| e.to_str()), | |
| Some("app"), | |
| "relaunch target must be a .app bundle on macOS" | |
| ); | |
| } | |
| { | |
| assert_eq!(resolved, expected); | |
| } | |
| let _ = std::fs::remove_dir_all(&root); | |
| } | |
| fn resolve_hermes_desktop_app_is_none_without_a_build() { | |
| let root = unique_tmp_dir("app-none"); | |
| // No release tree created. | |
| assert!( | |
| resolve_hermes_desktop_app(&root).is_none(), | |
| "no resolved app when nothing has been built" | |
| ); | |
| let _ = std::fs::remove_dir_all(&root); | |
| } | |
| fn bootstrap_complete_marker_uses_desktop_compatible_schema() { | |
| let root = unique_tmp_dir("marker-schema"); | |
| let pin = Pin { | |
| commit: Some("abcdef1234567890".to_string()), | |
| branch: Some("main".to_string()), | |
| }; | |
| let marker = | |
| write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed"); | |
| let marker_path = root.join(".hermes-bootstrap-complete"); | |
| let from_disk: serde_json::Value = | |
| serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap(); | |
| assert_eq!(marker, from_disk); | |
| assert_eq!(from_disk["schemaVersion"], 1); | |
| assert_eq!(from_disk["pinnedCommit"], "abcdef1234567890"); | |
| assert_eq!(from_disk["pinnedBranch"], "main"); | |
| assert!( | |
| from_disk["completedAtUnix"].as_u64().is_some(), | |
| "marker must carry a completion timestamp" | |
| ); | |
| let _ = std::fs::remove_dir_all(&root); | |
| } | |
| fn bootstrap_complete_marker_is_published_atomically() { | |
| let root = unique_tmp_dir("marker-atomic"); | |
| make_release_tree(&root); | |
| let pin = Pin { | |
| commit: Some("abcdef1234567890".to_string()), | |
| branch: Some("main".to_string()), | |
| }; | |
| write_bootstrap_complete_marker(&root, &pin).expect("marker write should succeed"); | |
| let marker_path = root.join(".hermes-bootstrap-complete"); | |
| let tmp_path = root.join(".hermes-bootstrap-complete.tmp"); | |
| assert!( | |
| marker_path.is_file(), | |
| "final marker must exist after atomic publish" | |
| ); | |
| assert!( | |
| !tmp_path.exists(), | |
| "temp sibling must not remain after atomic publish" | |
| ); | |
| assert!( | |
| hermes_is_installed(&root), | |
| "atomically published marker must enable the installer fast path" | |
| ); | |
| let _ = std::fs::remove_dir_all(&root); | |
| } | |
| fn hermes_is_installed_treats_marker_existence_as_sufficient() { | |
| // Documents why write_bootstrap_complete_marker must publish atomically: | |
| // the launcher predicate only checks existence, so a partial/corrupt | |
| // final marker would still enable the fast path. | |
| let root = unique_tmp_dir("marker-existence-only"); | |
| make_release_tree(&root); | |
| std::fs::write(root.join(".hermes-bootstrap-complete"), b"").unwrap(); | |
| assert!( | |
| hermes_is_installed(&root), | |
| "empty/partial marker content still counts as installed" | |
| ); | |
| let _ = std::fs::remove_dir_all(&root); | |
| } | |
| fn marker_write_failure_leaves_no_final_marker() { | |
| // install_root is a regular file → create_dir_all on its path fails | |
| // before any marker bytes are published under the final name. | |
| let base = unique_tmp_dir("marker-fail"); | |
| let not_a_dir = base.join("not-a-dir"); | |
| std::fs::write(¬_a_dir, b"not a directory").unwrap(); | |
| let pin = Pin { | |
| commit: Some("abcdef1234567890".to_string()), | |
| branch: Some("main".to_string()), | |
| }; | |
| let err = write_bootstrap_complete_marker(¬_a_dir, &pin) | |
| .expect_err("marker write against a non-directory root must fail"); | |
| let msg = format!("{err:#}"); | |
| assert!( | |
| msg.contains("bootstrap marker"), | |
| "error should mention the marker path: {msg}" | |
| ); | |
| assert!( | |
| !not_a_dir.join(".hermes-bootstrap-complete").exists(), | |
| "failed write must not leave a final marker that enables the fast path" | |
| ); | |
| assert!( | |
| !not_a_dir.join(".hermes-bootstrap-complete.tmp").exists(), | |
| "failed write must not leave a temp marker sibling either" | |
| ); | |
| let _ = std::fs::remove_dir_all(&base); | |
| } | |
| fn abrupt_windows_stage_exit_is_retried_but_never_forever() { | |
| assert!(should_retry_missing_stage_frame(Some(-1), false, 1)); | |
| assert!(should_retry_missing_stage_frame(Some(-1), false, 2)); | |
| assert!( | |
| !should_retry_missing_stage_frame(Some(-1), false, MAX_STAGE_ATTEMPTS), | |
| "the retry policy must stay bounded" | |
| ); | |
| } | |
| fn ordinary_failure_or_cancellation_is_not_retried_without_a_frame() { | |
| assert!(!should_retry_missing_stage_frame(Some(1), false, 1)); | |
| assert!(!should_retry_missing_stage_frame(Some(0), false, 1)); | |
| assert!(!should_retry_missing_stage_frame(None, false, 1)); | |
| assert!(!should_retry_missing_stage_frame(Some(-1), true, 1)); | |
| } | |
| async fn cancellation_during_retry_backoff_stops_the_retry() { | |
| let (tx, mut rx) = mpsc::channel(1); | |
| tx.send(()).await.unwrap(); | |
| assert!(retry_backoff_cancelled(Some(&mut rx)).await); | |
| } | |
| } | |