From 9b6f35c81a80a66b469fd15aeca45e51ef6c97b6 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 11 Sep 2026 09:44:34 -0700 Subject: [PATCH] fix(desktop): pin the latest published deployment on first install --- CHANGELOG.md | 6 + desktop/src-tauri/src/deployment_release.rs | 140 ++++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/main.rs | 87 ++++++------ 4 files changed, 196 insertions(+), 38 deletions(-) create mode 100644 desktop/src-tauri/src/deployment_release.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f29a867b..a47c0c8c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### Fresh desktop installs pin the latest published deployment + +The desktop app resolves GitHub's latest published release on first setup and downloads that exact +tag's source and image manifest. It records the version after both downloads finish and reuses it +on subsequent starts, so new installs no longer stay tied to the app's old v0.0.8 default. + ### The Bot computer refuses a malformed scroll or live input before the browser sees it A non-finite wheel delta travelled into Playwright and came back as a 502 that read as a broken diff --git a/desktop/src-tauri/src/deployment_release.rs b/desktop/src-tauri/src/deployment_release.rs new file mode 100644 index 000000000..14a51f97b --- /dev/null +++ b/desktop/src-tauri/src/deployment_release.rs @@ -0,0 +1,140 @@ +//! Choose a release for a new deployment and retain an existing deployment's exact version. + +use std::path::Path; + +use serde::Deserialize; + +use crate::deployment; + +const LATEST_RELEASE: &str = "https://api.github.com/repos/CopilotKit/OpenBot/releases/latest"; + +/// Only new deployments consult GitHub. The fetcher records the exact tag after the source and +/// image manifest have both downloaded successfully; restarts and repairs retain that pin. +/// Uses blocking HTTP, so callers in an async runtime must use a blocking task. +pub fn resolve_version(root: &Path) -> Result { + resolve_version_with(root, || { + let body = deployment::get(LATEST_RELEASE) + .map_err(|error| format!("could not find the latest OpenBot release: {error}"))?; + release_tag(&body) + }) +} + +fn resolve_version_with( + root: &Path, + latest: impl FnOnce() -> Result, +) -> Result { + match deployment::installed(root) { + Some(installed) => Ok(installed.version), + None => latest(), + } +} + +fn release_tag(body: &[u8]) -> Result { + #[derive(Deserialize)] + struct Release { + tag_name: String, + } + + let release: Release = serde_json::from_slice(body) + .map_err(|error| format!("the latest OpenBot release is not readable: {error}"))?; + if release.tag_name.trim().is_empty() { + return Err("the latest OpenBot release has no version tag".into()); + } + Ok(release.tag_name) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::temp_root; + + fn scratch(label: &str) -> std::path::PathBuf { + let root = temp_root(label); + std::fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn a_fresh_install_selects_latest_without_recording_an_unfinished_download() { + let root = scratch("release-fresh"); + let version = resolve_version_with(&root, || Ok("v0.0.9".into())).unwrap(); + assert_eq!(version, "v0.0.9"); + assert!(deployment::installed(&root).is_none()); + assert!(deployment::needs_fetch(&root, &version)); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn an_installed_version_does_not_query_github_or_upgrade() { + let root = scratch("release-installed"); + deployment::record(&root, "v0.0.7").unwrap(); + std::fs::write(deployment::images_path(&root), "{}").unwrap(); + let version = resolve_version_with(&root, || panic!("must work offline")).unwrap(); + assert_eq!(version, "v0.0.7"); + assert!(!deployment::needs_fetch(&root, &version)); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn a_missing_manifest_repairs_the_pinned_version() { + let root = scratch("release-repair"); + deployment::record(&root, "v0.0.7").unwrap(); + let version = resolve_version_with(&root, || panic!("keep the installed pin")).unwrap(); + assert_eq!(version, "v0.0.7"); + assert!(deployment::needs_fetch(&root, &version)); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn a_failed_lookup_is_reported_without_recording_a_version() { + let root = scratch("release-lookup-failed"); + let error = resolve_version_with(&root, || Err("GitHub answered 403".into())) + .expect_err("a failed lookup must not fall back to an old release"); + assert!(error.contains("403")); + assert!(deployment::installed(&root).is_none()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn githubs_exact_tag_is_used_instead_of_the_release_title() { + assert_eq!( + release_tag(br#"{"name":"OpenBot September release","tag_name":"v0.0.9"}"#).unwrap(), + "v0.0.9" + ); + } + + #[test] + fn missing_or_unreadable_release_metadata_is_refused() { + for body in [b"not json".as_slice(), b"{}", br#"{"tag_name":" "}"#] { + assert!(release_tag(body).is_err()); + } + } + + /// Exercises the production resolver and downloader against GitHub in an empty directory. + #[test] + #[ignore = "downloads the latest public release from GitHub"] + fn live_latest_release_is_downloaded_and_pinned() { + let root = scratch("release-live"); + let version = resolve_version(&root).expect("resolve the public release"); + assert!(deployment::installed(&root).is_none()); + deployment::fetch(&root, &version).expect("download the tagged deployment and manifest"); + assert_eq!(deployment::installed(&root).unwrap().version, version); + let images: deployment::Images = + serde_json::from_slice(&std::fs::read(deployment::images_path(&root)).unwrap()) + .unwrap(); + assert_eq!(images.version, version); + let package: serde_json::Value = + serde_json::from_slice(&std::fs::read(root.join("package.json")).unwrap()).unwrap(); + assert_eq!(package["version"], version.trim_start_matches('v')); + for path in deployment::REQUIRED { + assert!(root.join(path).exists(), "missing {path}"); + } + assert_eq!( + resolve_version_with(&root, || panic!("restart must not need GitHub")).unwrap(), + version + ); + assert!(!deployment::needs_fetch(&root, &version)); + println!("Downloaded and pinned {version}; tagged source, image manifest, and offline reuse verified."); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index edadb8cc5..7b960bf54 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ pub mod acquire; pub mod ask; pub mod deployment; +pub mod deployment_release; pub mod engine; pub mod env; pub mod harness; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 27cee297c..52d0534ef 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -8,16 +8,10 @@ use std::sync::Mutex; mod test_support; use openbot_desktop_lib::{ - acquire, deployment, engine, env as openbot_env, harness, install, problem::Problem, provider, - quiet, stack, supervise, tray, windows as win, + acquire, deployment, deployment_release, engine, env as openbot_env, harness, install, + problem::Problem, provider, quiet, stack, supervise, tray, windows as win, }; -/// The deployment this app installs. -/// -/// Pinned rather than "latest": the images a release runs are pinned per release, so the tree that -/// names them has to be too, and an app that fetches whatever shipped this morning is not a version -/// anybody can be given. Moved deliberately, with the app. -const DEPLOYMENT_VERSION: &str = "v0.0.8"; const QUIT_CLEANUP_NOTICE_FILE: &str = ".openbot-quit-cleanup-notice"; const QUIT_CLEANUP_NOTICE_LIMIT: usize = 16 * 1024; use serde::{Deserialize, Serialize}; @@ -427,47 +421,43 @@ async fn engine_ready(app: &tauri::AppHandle) -> Result( app: &tauri::AppHandle, root: &Path, ) -> Result<(), Problem> { - if deployment::needs_fetch(root, DEPLOYMENT_VERSION) { - report( - app, - "deployment", - true, - format!("fetching {DEPLOYMENT_VERSION}"), - ); - // On a blocking thread, not this one. A blocking HTTP client builds its own runtime, and - // dropping one inside an async context panics the worker rather than returning an error: - // "Cannot drop a runtime in a context where blocking is not allowed". The window survives - // that, which is worse than a crash, because the only symptom is a step that never ends. - let target = root.to_path_buf(); - tauri::async_runtime::spawn_blocking(move || { - deployment::fetch(&target, DEPLOYMENT_VERSION) - }) - .await - .map_err(|error| { - Problem::with( - "OpenBot could not download what it needs to run. Check the internet \ - connection and try again.", - format!("the download did not run: {error}"), - ) - })? - .inspect_err(|error| { - report(app, "deployment", false, error.clone()); - })?; - } + // Both release discovery and downloading use blocking HTTP. Keeping them in a blocking task + // avoids dropping reqwest's runtime inside this async context. + let target = root.to_path_buf(); + let handle = app.clone(); + let version = tauri::async_runtime::spawn_blocking(move || { + let version = deployment_release::resolve_version(&target)?; + if deployment::needs_fetch(&target, &version) { + report(&handle, "deployment", true, format!("fetching {version}")); + deployment::fetch(&target, &version)?; + } + Ok::<_, String>(version) + }) + .await + .map_err(|error| format!("the download did not run: {error}")) + .and_then(|result| result) + .map_err(|error| { + report(app, "deployment", false, error.clone()); + Problem::with( + "OpenBot could not download what it needs to run. Check the internet \ + connection and try again.", + error, + ) + })?; report( app, "deployment", true, - format!("{DEPLOYMENT_VERSION} in {}", root.display()), + format!("{version} in {}", root.display()), ); Ok(()) } @@ -3296,6 +3286,26 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn deployment_ready_preserves_the_installed_release() { + let root = temp_root("deployment-ready-pinned"); + write_installed_deployment(&root); + deployment::record(&root, "v0.0.7").unwrap(); + let app = tauri::test::mock_builder() + .manage(Shell::default()) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .unwrap(); + + tauri::async_runtime::block_on(deployment_ready(app.handle(), &root)).unwrap(); + + assert_eq!(deployment::installed(&root).unwrap().version, "v0.0.7"); + assert_eq!( + std::fs::read_to_string(root.join("docker-compose.yml")).unwrap(), + "services: {}\n" + ); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn plan_sign_in_boundary_uses_selected_root_for_deploy_and_reference() { let default = temp_root("signin-default-root"); @@ -6268,6 +6278,7 @@ fn main() { } fn write_installed_deployment(root: &Path) { + const DEPLOYMENT_VERSION: &str = "v0.0.8"; std::fs::create_dir_all(root.join("server")).unwrap(); std::fs::create_dir_all(root.join("app")).unwrap(); std::fs::create_dir_all(root.join("worker")).unwrap();