mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
fix(installer): stamp the bootstrap-complete marker from the Rust installer
The macOS launcher fast path gates on hermes_is_installed(), which needs .hermes-bootstrap-complete next to a built desktop app. Nothing in the Rust bootstrap pipeline ever wrote that marker -- only install.ps1 did -- so every reopen of /Applications/Hermes.app re-ran setup instead of launching. Publish the marker atomically (temp sibling + fsync + rename) because hermes_is_installed() only checks existence: a torn direct write would arm the fast path against a half-installed tree. A marker write failure emits BootstrapEvent::Failed so the installer UI leaves the progress state. Co-authored-by: giggling-ginger <giggling-ginger@users.noreply.github.com>
This commit is contained in:
parent
6fc571afe9
commit
ea3be4191e
2 changed files with 223 additions and 9 deletions
|
|
@ -12,11 +12,11 @@
|
|||
//! 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::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
|
@ -260,6 +260,107 @@ pub(crate) fn hermes_is_installed(install_root: &std::path::Path) -> bool {
|
|||
&& 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.
|
||||
|
|
@ -644,6 +745,23 @@ async fn run_bootstrap(
|
|||
.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
|
||||
|
|
@ -660,10 +778,7 @@ async fn run_bootstrap(
|
|||
&app,
|
||||
BootstrapEvent::Complete {
|
||||
install_root: install_root.to_string_lossy().into_owned(),
|
||||
marker: Some(serde_json::json!({
|
||||
"pinnedCommit": pin.commit,
|
||||
"pinnedBranch": pin.branch,
|
||||
})),
|
||||
marker: Some(marker),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -903,4 +1018,103 @@ mod tests {
|
|||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,8 +149,8 @@ fn repair_macos_installer_helper(path: &Path) {
|
|||
#[cfg(not(target_os = "macos"))]
|
||||
fn repair_macos_installer_helper(_path: &Path) {}
|
||||
|
||||
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
|
||||
/// the Electron app also checks). Per main.ts:
|
||||
/// Where the bootstrap-complete marker lives (existence-only for the Rust
|
||||
/// installer fast path; JSON schema-checked by the Electron app). Per main.ts:
|
||||
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
|
||||
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
|
||||
/// this is a probe helper, not a definitive path.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue