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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions desktop/src-tauri/src/deployment_release.rs
Original file line number Diff line number Diff line change
@@ -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<String, String> {
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<String, String>,
) -> Result<String, String> {
match deployment::installed(root) {
Some(installed) => Ok(installed.version),
None => latest(),
}
}

fn release_tag(body: &[u8]) -> Result<String, String> {
#[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();
}
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
87 changes: 49 additions & 38 deletions desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -427,47 +421,43 @@ async fn engine_ready(app: &tauri::AppHandle) -> Result<engine::Address, Problem
})
}

/// The deployment on disk, fetched if it is not there or is the wrong version.
/// Install the latest published deployment on first use, then keep its recorded version.
///
/// Extracted from `start_stack` because Start is no longer the only thing that needs it: a plan
/// sign-in runs a published image, and the reference for that image is read from the manifest this
/// lays down. Skipped when the recorded version already matches, so a restart is not a download.
/// lays down. An installed deployment keeps its exact tag without consulting GitHub again.
async fn deployment_ready<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
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(())
}
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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();
Expand Down