diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index b86406d9b0..b0916ab91c 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -129,6 +129,7 @@ export default defineConfig({ "**/observer-archive-policy.spec.ts", "**/harness-management.spec.ts", "**/harness-catalog-screenshots.spec.ts", + "**/goose-update-screenshots.spec.ts", "**/inline-custom-harness.spec.ts", ], use: { diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 325eb9aa67..6ff3d1a25a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1074,6 +1074,7 @@ dependencies = [ "rusqlite", "rustls", "security-framework 3.7.0", + "semver", "serde", "serde_json", "serde_yaml", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6f3c03c5a5..6335a12b9b 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -93,6 +93,7 @@ getrandom = "0.2" zeroize = "1" reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] } +semver = "1" url = "2" buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" } buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" } diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index cbbf4ce351..f1ec5dbbad 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -11,6 +11,7 @@ use crate::{ relay::query_relay, }; +mod cli_setup; mod post_install_verification; fn active_installs() -> &'static std::sync::Mutex> { @@ -316,16 +317,12 @@ fn install_acp_runtime_blocking( // Today every entry in `cli_install_commands` is a curl-pipe; npm-backed // adapter installs live in Phase 2 below where they are rewritten to a // Buzz-private prefix before execution. - if let Some(cli) = runtime.underlying_cli { - if crate::managed_agents::resolve_command(cli).is_none() { - for cmd in runtime.cli_install_commands_for_os() { - let result = run_install_command_with_retry("cli", cmd, &reporter); - let success = result.success; - steps.push(result); - if !success { - return Ok(reporter.failed(steps)); - } - } + for (step, command) in cli_setup::plan(runtime) { + let result = run_install_command_with_retry(step, &command, &reporter); + let success = result.success; + steps.push(result); + if !success { + return Ok(reporter.failed(steps)); } } diff --git a/desktop/src-tauri/src/commands/agent_discovery/cli_setup.rs b/desktop/src-tauri/src/commands/agent_discovery/cli_setup.rs new file mode 100644 index 0000000000..d68408d5b8 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/cli_setup.rs @@ -0,0 +1,99 @@ +use std::path::Path; + +use crate::managed_agents::KnownAcpRuntime; + +/// Plan the vendor CLI portion of an explicit runtime setup request. +/// +/// Missing CLIs use the catalog installer. An already-installed Goose uses its +/// resolved executable for an optional user-requested update. Other installed +/// CLIs need no setup command. +pub(super) fn plan(runtime: &KnownAcpRuntime) -> Vec<(&'static str, String)> { + let Some(cli) = runtime.underlying_cli else { + return Vec::new(); + }; + let cli_path = crate::managed_agents::resolve_command(cli); + plan_commands( + runtime.id, + cli_path.as_deref(), + runtime.cli_install_commands_for_os(), + ) +} + +fn plan_commands( + runtime_id: &str, + cli_path: Option<&Path>, + install_commands: &[&str], +) -> Vec<(&'static str, String)> { + match cli_path { + Some(path) if runtime_id == "goose" => { + vec![("update", goose_update_command(path))] + } + Some(_) => Vec::new(), + None => install_commands + .iter() + .map(|command| ("cli", (*command).to_string())) + .collect(), + } +} + +#[cfg(not(windows))] +fn goose_update_command(path: &Path) -> String { + let path = path.display().to_string(); + format!("'{}' update", path.replace('\'', "'\"'\"'")) +} + +#[cfg(windows)] +fn goose_update_command(path: &Path) -> String { + let path = path.display().to_string(); + let quoted_path = format!("'{}'", path.replace('\'', "''")); + format!("powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"& {quoted_path} update\"") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_goose_uses_catalog_installer() { + assert_eq!( + plan_commands("goose", None, &["install goose"]), + vec![("cli", "install goose".to_string())] + ); + } + + #[cfg(not(windows))] + #[test] + fn installed_goose_updates_resolved_binary() { + let path = Path::new("/tmp/Buzz's Goose/goose"); + + assert_eq!( + plan_commands("goose", Some(path), &["install goose"]), + vec![( + "update", + "'/tmp/Buzz'\"'\"'s Goose/goose' update".to_string() + )] + ); + } + + #[cfg(windows)] + #[test] + fn installed_goose_uses_native_powershell() { + let path = Path::new(r"C:\Users\lifei\Buzz's Goose\goose.exe"); + + assert_eq!( + plan_commands("goose", Some(path), &["install goose"]), + vec![( + "update", + r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "& 'C:\Users\lifei\Buzz''s Goose\goose.exe' update""# + .to_string() + )] + ); + } + + #[test] + fn installed_non_goose_is_unchanged() { + let path = Path::new("/usr/local/bin/claude"); + + assert!(plan_commands("claude", Some(path), &["install claude"]).is_empty()); + } +} diff --git a/desktop/src-tauri/src/commands/goose_update.rs b/desktop/src-tauri/src/commands/goose_update.rs new file mode 100644 index 0000000000..380babf114 --- /dev/null +++ b/desktop/src-tauri/src/commands/goose_update.rs @@ -0,0 +1,334 @@ +use std::{ + io::{Read as _, Seek as _, SeekFrom}, + path::Path, + process::Command, + time::{Duration, Instant}, +}; + +use semver::Version; +use serde::Serialize; + +const LATEST_GOOSE_RELEASE_URL: &str = "https://github.com/aaif-goose/goose/releases/latest"; +const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(5); +const RELEASE_CHECK_TIMEOUT: Duration = Duration::from_secs(5); + +/// Read-only comparison between the installed Goose CLI and the latest stable +/// Goose release. +/// +/// This is intentionally separate from runtime availability: failure to check +/// for an update must never make an installed Goose runtime unavailable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum GooseUpdateStatus { + /// The installed version is equal to or newer than the latest stable release. + UpToDate { + installed_version: String, + latest_version: String, + }, + /// A newer stable release is available for the installed Goose CLI. + UpdateAvailable { + installed_version: String, + latest_version: String, + }, +} + +/// Check whether the resolved Goose CLI is behind the latest stable release. +/// +/// The command is called only by Settings. It never runs `goose update` and +/// therefore cannot modify the installed binary. +#[tauri::command] +pub async fn check_goose_update_status() -> Result { + let installed_version = tokio::task::spawn_blocking(|| { + let path = crate::managed_agents::resolve_command("goose") + .ok_or_else(|| "Goose is not installed.".to_string())?; + probe_goose_version(&path) + }) + .await + .map_err(|error| format!("Goose version probe task failed: {error}"))??; + + let client = reqwest::Client::builder() + .timeout(RELEASE_CHECK_TIMEOUT) + .user_agent("buzz-desktop") + .build() + .map_err(|error| format!("Could not create the Goose update client: {error}"))?; + let latest_version = fetch_latest_stable_version(&client, LATEST_GOOSE_RELEASE_URL).await?; + + Ok(classify_versions(installed_version, latest_version)) +} + +fn probe_goose_version(path: &Path) -> Result { + probe_goose_version_with_timeout(path, VERSION_PROBE_TIMEOUT) +} + +fn probe_goose_version_with_timeout(path: &Path, timeout: Duration) -> Result { + let mut stdout = tempfile::tempfile() + .map_err(|error| format!("Could not capture Goose version output: {error}"))?; + let stderr = tempfile::tempfile() + .map_err(|error| format!("Could not capture Goose version errors: {error}"))?; + + let mut command = Command::new(path); + command + .arg("--version") + .stdout( + stdout + .try_clone() + .map_err(|error| format!("Could not capture Goose version output: {error}"))?, + ) + .stderr(stderr); + crate::util::configure_no_window(&mut command); + + let mut child = command + .spawn() + .map_err(|error| format!("Could not run {} --version: {error}", path.display()))?; + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(50)); + } + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "{} --version timed out after {} seconds.", + path.display(), + timeout.as_secs() + )); + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "Could not wait for {} --version: {error}", + path.display() + )); + } + } + }; + + if !status.success() { + return Err(format!( + "{} --version exited with {status}.", + path.display() + )); + } + + stdout + .seek(SeekFrom::Start(0)) + .map_err(|error| format!("Could not read Goose version output: {error}"))?; + let mut output = String::new(); + (&mut stdout as &mut dyn std::io::Read) + .take(4096) + .read_to_string(&mut output) + .map_err(|error| format!("Could not read Goose version output: {error}"))?; + + parse_installed_version(&output) +} + +fn parse_installed_version(output: &str) -> Result { + let raw = output + .split_whitespace() + .last() + .ok_or_else(|| "Goose did not report an installed version.".to_string())?; + let version = raw.strip_prefix('v').unwrap_or(raw); + Version::parse(version) + .map_err(|error| format!("Could not parse installed Goose version {raw:?}: {error}")) +} + +async fn fetch_latest_stable_version( + client: &reqwest::Client, + release_url: &str, +) -> Result { + let response = client + .head(release_url) + .send() + .await + .map_err(|error| format!("Could not check the latest Goose release: {error}"))?; + + if !response.status().is_success() { + return Err(format!( + "Goose release check returned HTTP {}.", + response.status() + )); + } + + parse_latest_release_url(response.url()) +} + +fn parse_latest_release_url(url: &reqwest::Url) -> Result { + if url.host_str() != Some("github.com") { + return Err(format!( + "Goose release check redirected to an unexpected host: {url}" + )); + } + + let segments = url + .path_segments() + .map(|segments| segments.collect::>()) + .unwrap_or_default(); + let [owner, repository, releases, tag, raw_version] = segments.as_slice() else { + return Err(format!( + "Goose release check returned an unexpected URL: {url}" + )); + }; + if (*owner, *repository, *releases, *tag) != ("aaif-goose", "goose", "releases", "tag") { + return Err(format!( + "Goose release check returned an unexpected URL: {url}" + )); + } + + let version = raw_version.strip_prefix('v').unwrap_or(raw_version); + Version::parse(version) + .map_err(|error| format!("Could not parse latest Goose version {raw_version:?}: {error}")) +} + +fn classify_versions(installed: Version, latest: Version) -> GooseUpdateStatus { + let installed_version = installed.to_string(); + let latest_version = latest.to_string(); + + if installed < latest { + GooseUpdateStatus::UpdateAvailable { + installed_version, + latest_version, + } + } else { + GooseUpdateStatus::UpToDate { + installed_version, + latest_version, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_installed_goose_version() { + assert_eq!( + parse_installed_version("1.44.0\n").expect("plain version"), + Version::new(1, 44, 0) + ); + assert_eq!( + parse_installed_version("goose v1.45.0\n").expect("prefixed version"), + Version::new(1, 45, 0) + ); + } + + #[test] + fn rejects_malformed_installed_version() { + let error = parse_installed_version("goose latest").expect_err("must reject"); + assert!(error.contains("Could not parse installed Goose version")); + } + + #[test] + fn parses_official_latest_release_redirect() { + let url = reqwest::Url::parse("https://github.com/aaif-goose/goose/releases/tag/v1.45.0") + .expect("valid URL"); + + assert_eq!( + parse_latest_release_url(&url).expect("official release"), + Version::new(1, 45, 0) + ); + } + + #[test] + fn rejects_unexpected_release_redirects() { + for raw in [ + "https://example.com/aaif-goose/goose/releases/tag/v1.45.0", + "https://github.com/other/goose/releases/tag/v1.45.0", + "https://github.com/aaif-goose/goose/releases/latest", + "https://github.com/aaif-goose/goose/releases/tag/stable", + ] { + let url = reqwest::Url::parse(raw).expect("valid URL"); + assert!(parse_latest_release_url(&url).is_err(), "must reject {raw}"); + } + } + + #[test] + fn classifies_behind_equal_and_newer_versions() { + assert!(matches!( + classify_versions(Version::new(1, 44, 0), Version::new(1, 45, 0)), + GooseUpdateStatus::UpdateAvailable { .. } + )); + assert!(matches!( + classify_versions(Version::new(1, 45, 0), Version::new(1, 45, 0)), + GooseUpdateStatus::UpToDate { .. } + )); + assert!(matches!( + classify_versions(Version::new(1, 46, 0), Version::new(1, 45, 0)), + GooseUpdateStatus::UpToDate { .. } + )); + } + + #[test] + fn stable_release_supersedes_same_version_prerelease() { + let installed = Version::parse("1.45.0-rc.1").expect("valid prerelease"); + let latest = Version::new(1, 45, 0); + + assert!(matches!( + classify_versions(installed, latest), + GooseUpdateStatus::UpdateAvailable { .. } + )); + } + + #[cfg(unix)] + #[test] + fn probes_resolved_goose_executable() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().expect("temp dir"); + let executable = dir.path().join("goose"); + std::fs::write(&executable, "#!/bin/sh\necho 1.44.0\n").expect("write fake Goose"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake Goose metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).expect("make fake Goose executable"); + + assert_eq!( + probe_goose_version_with_timeout(&executable, Duration::from_secs(3)) + .expect("version probe"), + Version::new(1, 44, 0) + ); + } + + #[cfg(unix)] + #[test] + fn reports_failed_version_probe() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().expect("temp dir"); + let executable = dir.path().join("goose"); + std::fs::write(&executable, "#!/bin/sh\nexit 2\n").expect("write failing fake Goose"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake Goose metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).expect("make fake Goose executable"); + + let error = probe_goose_version_with_timeout(&executable, Duration::from_secs(3)) + .expect_err("probe must fail"); + assert!(error.contains("exited with")); + } + + #[cfg(unix)] + #[test] + fn bounds_hung_version_probe() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = tempfile::tempdir().expect("temp dir"); + let executable = dir.path().join("goose"); + std::fs::write(&executable, "#!/bin/sh\nexec sleep 10\n").expect("write hung fake Goose"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake Goose metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).expect("make fake Goose executable"); + + let error = probe_goose_version_with_timeout(&executable, Duration::from_millis(100)) + .expect_err("probe must time out"); + assert!(error.contains("timed out")); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..a9f83ba5a2 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -19,6 +19,7 @@ mod dms; mod engrams; mod export_util; mod global_agent_config; +mod goose_update; mod identity; mod identity_archive; mod join_policy; @@ -78,6 +79,7 @@ pub use clipboard::*; pub use dms::*; pub use engrams::*; pub use global_agent_config::*; +pub use goose_update::*; pub use identity::*; pub use identity_archive::*; pub use join_policy::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ee2a98f5c1..cd63bc4b20 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -716,6 +716,7 @@ pub fn run() { discover_acp_auth_methods, discover_acp_providers, discover_git_bash_prerequisite, + check_goose_update_status, install_acp_runtime, save_custom_harness, delete_custom_harness, diff --git a/desktop/src/features/agents/gooseUpdateHooks.ts b/desktop/src/features/agents/gooseUpdateHooks.ts new file mode 100644 index 0000000000..07fe794460 --- /dev/null +++ b/desktop/src/features/agents/gooseUpdateHooks.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; + +import { checkGooseUpdateStatus } from "@/shared/api/tauriGooseUpdates"; + +export const gooseUpdateStatusQueryKey = ["goose-update-status"] as const; + +/** Settings-scoped, session-cached check for a newer stable Goose release. */ +export function useGooseUpdateStatusQuery(options?: { enabled?: boolean }) { + return useQuery({ + enabled: options?.enabled ?? true, + gcTime: Number.POSITIVE_INFINITY, + queryFn: checkGooseUpdateStatus, + queryKey: gooseUpdateStatusQueryKey, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + retry: false, + retryOnMount: false, + staleTime: Number.POSITIVE_INFINITY, + }); +} diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 122c872e54..6501fec858 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -39,6 +39,7 @@ import { updateManagedAgent, } from "@/shared/api/tauri"; import type { HarnessDefinitionInput } from "@/shared/api/tauri"; +import { shouldRefreshGooseUpdateStatus } from "@/shared/api/tauriGooseUpdates"; import { setManagedAgentAutoRestart, setManagedAgentStartOnAppLaunch, @@ -46,6 +47,7 @@ import { stopManagedAgent, } from "@/shared/api/tauriManagedAgents"; import { bootstrapManagedAgentRuntimePairs } from "@/features/agents/managedAgentRuntimeHooks"; +import { gooseUpdateStatusQueryKey } from "@/features/agents/gooseUpdateHooks"; import { createPersona, deletePersona, @@ -233,6 +235,13 @@ export function useInstallAcpRuntimeMutation() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (runtimeId: string) => installAcpRuntime(runtimeId), + onSuccess: (result, runtimeId) => { + if (shouldRefreshGooseUpdateStatus(runtimeId, result.success)) { + void queryClient.invalidateQueries({ + queryKey: gooseUpdateStatusQueryKey, + }); + } + }, onSettled: () => { void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey }); void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); diff --git a/desktop/src/features/settings/ui/HarnessRow.tsx b/desktop/src/features/settings/ui/HarnessRow.tsx index c0feef13cc..51cde859dd 100644 --- a/desktop/src/features/settings/ui/HarnessRow.tsx +++ b/desktop/src/features/settings/ui/HarnessRow.tsx @@ -39,6 +39,7 @@ import { adapterUpdateWarning, entryStatusLabel, isDownloadPageUrl, + runtimeRowSetupAction, } from "./harnessCatalogLogic"; import { formValuesFromCatalogEntry } from "./harnessFormLogic"; import { deleteConfirmState } from "./harnessGalleryLogic"; @@ -175,6 +176,7 @@ function RuntimeActions({ authMethods, connectingMethodId, isConnecting, + isGooseUpdateAvailable, isInstalling, onConnect, onDelete, @@ -185,6 +187,7 @@ function RuntimeActions({ authMethods: AcpAuthMethod[]; connectingMethodId: string | null; isConnecting: boolean; + isGooseUpdateAvailable: boolean; isInstalling: boolean; onConnect: (method: AcpAuthMethod) => void; onDelete?: () => void; @@ -198,8 +201,12 @@ function RuntimeActions({ // must not claim otherwise. const isAuthNeeded = isAvailable && runtime.authStatus.status === "logged_out"; - const canInstall = runtime.canAutoInstall && !runtime.nodeRequired; + const setupAction = runtimeRowSetupAction(runtime, isGooseUpdateAvailable); const isWorking = isInstalling || isConnecting; + const workingLabel = + isInstalling && runtime.id === "goose" && isGooseUpdateAvailable + ? "updating" + : "installing"; return (
@@ -215,35 +222,39 @@ function RuntimeActions({ {isWorking ? (
- ) : isAvailable ? ( - isAuthNeeded ? null : ( // Signed-out rows carry the amber status chip instead; never Install. - - Ready - - ) - ) : canInstall ? ( - // Rows needing multi-step setup render no action here — setup lives in - // the Add-runtimes catalog. Custom rows keep their ••• menu instead. - - ) : null} + ) : ( + <> + {isAvailable && !isAuthNeeded ? ( + + Ready + + ) : null} + {setupAction ? ( + // Rows needing multi-step setup render no action here — setup lives + // in the Add-runtimes catalog. Ready Goose is the single optional + // update exception and remains visibly Ready beside this button. + + ) : null} + + )}
); } @@ -295,9 +306,11 @@ function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { * — for custom harnesses — edit and delete with the blast-radius guard. */ export function HarnessRow({ + isGooseUpdateAvailable, resetEpoch, runtime, }: { + isGooseUpdateAvailable: boolean; resetEpoch: number; runtime: AcpRuntimeCatalogEntry; }) { @@ -357,7 +370,12 @@ export function HarnessRow({ onError: (error) => { setInstallResult({ success: false, - error: error instanceof Error ? error.message : "Install failed.", + error: + error instanceof Error + ? error.message + : runtime.id === "goose" && isGooseUpdateAvailable + ? "Update failed." + : "Install failed.", }); }, }); @@ -416,6 +434,7 @@ export function HarnessRow({ authMethods={authMethods} connectingMethodId={connectMutation.variables?.methodId ?? null} isConnecting={connectMutation.isPending} + isGooseUpdateAvailable={isGooseUpdateAvailable} isInstalling={isInstalling} onConnect={(method) => { setTerminalLaunchMethodId(null); diff --git a/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx b/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx index 10ca2a377f..a3cafcc757 100644 --- a/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/HarnessesSettingsPanel.tsx @@ -6,6 +6,7 @@ import { useAcpRuntimesQuery, useGitBashPrerequisiteQuery, } from "@/features/agents/hooks"; +import { useGooseUpdateStatusQuery } from "@/features/agents/gooseUpdateHooks"; import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -13,7 +14,11 @@ import { SectionHeader } from "@/shared/ui/PageHeader"; import { HarnessCatalogDialog } from "./HarnessCatalogDialog"; import { HarnessRow } from "./HarnessRow"; -import { stableRowOrder, yourHarnessEntries } from "./harnessCatalogLogic"; +import { + isConfirmedGooseUpdateAvailable, + stableRowOrder, + yourHarnessEntries, +} from "./harnessCatalogLogic"; function GitBashCard({ prerequisite, @@ -86,6 +91,17 @@ function GitBashCard({ export function HarnessesSettingsPanel() { const runtimesQuery = useAcpRuntimesQuery(); const gitBashQuery = useGitBashPrerequisiteQuery(); + const gooseAvailable = (runtimesQuery.data ?? []).some( + (runtime) => runtime.id === "goose" && runtime.availability === "available", + ); + const gooseUpdateQuery = useGooseUpdateStatusQuery({ + enabled: gooseAvailable, + }); + const isGooseUpdateAvailable = isConfirmedGooseUpdateAvailable( + gooseUpdateQuery.data?.status, + gooseUpdateQuery.isFetching, + gooseUpdateQuery.isError, + ); const [catalogOpen, setCatalogOpen] = React.useState(false); // Incremented each time the user clicks "Check again" so HarnessRow // useEffect clears stale install results from before the refresh. @@ -107,7 +123,7 @@ export function HarnessesSettingsPanel() { .filter((e): e is AcpRuntimeCatalogEntry => e !== undefined); }, [entries]); - const isRefreshing = runtimesQuery.isFetching; + const isRefreshing = runtimesQuery.isFetching || gooseUpdateQuery.isFetching; return (
@@ -122,6 +138,9 @@ export function HarnessesSettingsPanel() { setResetEpoch((e) => e + 1); void runtimesQuery.refetch(); void gitBashQuery.refetch(); + if (gooseAvailable) { + void gooseUpdateQuery.refetch(); + } }} size="sm" type="button" @@ -173,6 +192,9 @@ export function HarnessesSettingsPanel() {
{rows.map((runtime) => ( { assert.deepEqual(catalogPrimaryAction(entry({})), { kind: "none" }); }); }); + +// ── runtimeRowSetupAction ─────────────────────────────────────────────────── + +describe("runtimeRowSetupAction", () => { + it("offers Update for available Goose only when a newer release is confirmed", () => { + assert.equal( + runtimeRowSetupAction( + entry({ + id: "goose", + availability: "available", + canAutoInstall: true, + }), + true, + ), + "Update", + ); + }); + + it("does not offer Update for current or unchecked Goose", () => { + const goose = entry({ + id: "goose", + availability: "available", + canAutoInstall: true, + }); + + assert.equal(runtimeRowSetupAction(goose), null); + assert.equal(runtimeRowSetupAction(goose, false), null); + }); + + it("does not offer Update for other available runtimes", () => { + assert.equal( + runtimeRowSetupAction( + entry({ + id: "claude", + availability: "available", + canAutoInstall: true, + }), + true, + ), + null, + ); + }); + + it("keeps Install for missing Goose", () => { + assert.equal( + runtimeRowSetupAction( + entry({ + id: "goose", + availability: "not_installed", + canAutoInstall: true, + }), + ), + "Install", + ); + }); +}); + +describe("isConfirmedGooseUpdateAvailable", () => { + it("returns true only for a settled update-available result", () => { + assert.equal( + isConfirmedGooseUpdateAvailable("update_available", false, false), + true, + ); + assert.equal( + isConfirmedGooseUpdateAvailable("up_to_date", false, false), + false, + ); + assert.equal(isConfirmedGooseUpdateAvailable(null, false, false), false); + }); + + it("hides stale availability while checking or after an error", () => { + assert.equal( + isConfirmedGooseUpdateAvailable("update_available", true, false), + false, + ); + assert.equal( + isConfirmedGooseUpdateAvailable("update_available", false, true), + false, + ); + }); +}); diff --git a/desktop/src/features/settings/ui/harnessCatalogLogic.ts b/desktop/src/features/settings/ui/harnessCatalogLogic.ts index 2627135a77..69b58c7754 100644 --- a/desktop/src/features/settings/ui/harnessCatalogLogic.ts +++ b/desktop/src/features/settings/ui/harnessCatalogLogic.ts @@ -153,6 +153,32 @@ export function entryStatusLabel(entry: AcpRuntimeCatalogEntry): string | null { } } +/** + * Optional setup action rendered directly on a runtime row. + * + * Goose is the only ready runtime with an optional CLI update. The action is + * offered only after the Settings-only check confirms a newer stable release. + */ +export function runtimeRowSetupAction( + entry: AcpRuntimeCatalogEntry, + gooseUpdateAvailable = false, +): "Install" | "Update" | null { + if (!entry.canAutoInstall || entry.nodeRequired) return null; + if (entry.availability === "available") { + return entry.id === "goose" && gooseUpdateAvailable ? "Update" : null; + } + return entry.availability === "adapter_outdated" ? "Update" : "Install"; +} + +/** True only for a completed, successful check that found a newer Goose. */ +export function isConfirmedGooseUpdateAvailable( + status: "up_to_date" | "update_available" | null | undefined, + isFetching: boolean, + isError: boolean, +): boolean { + return status === "update_available" && !isFetching && !isError; +} + /** * Body copy for the confirmation dialog shown before replacing an * already-installed (but outdated) adapter. diff --git a/desktop/src/shared/api/tauriGooseUpdates.test.mjs b/desktop/src/shared/api/tauriGooseUpdates.test.mjs new file mode 100644 index 0000000000..df9045f2ea --- /dev/null +++ b/desktop/src/shared/api/tauriGooseUpdates.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { fromRawGooseUpdateStatus, shouldRefreshGooseUpdateStatus } = + await import("./tauriGooseUpdates.ts"); + +test("maps up-to-date Goose version fields", () => { + assert.deepStrictEqual( + fromRawGooseUpdateStatus({ + status: "up_to_date", + installed_version: "1.45.0", + latest_version: "1.45.0", + }), + { + status: "up_to_date", + installedVersion: "1.45.0", + latestVersion: "1.45.0", + }, + ); +}); + +test("maps update-available Goose version fields", () => { + assert.deepStrictEqual( + fromRawGooseUpdateStatus({ + status: "update_available", + installed_version: "1.44.0", + latest_version: "1.45.0", + }), + { + status: "update_available", + installedVersion: "1.44.0", + latestVersion: "1.45.0", + }, + ); +}); + +test("refreshes status only after successful Goose setup", () => { + assert.equal(shouldRefreshGooseUpdateStatus("goose", true), true); + assert.equal(shouldRefreshGooseUpdateStatus("goose", false), false); + assert.equal(shouldRefreshGooseUpdateStatus("claude", true), false); +}); diff --git a/desktop/src/shared/api/tauriGooseUpdates.ts b/desktop/src/shared/api/tauriGooseUpdates.ts new file mode 100644 index 0000000000..d69d26b8d4 --- /dev/null +++ b/desktop/src/shared/api/tauriGooseUpdates.ts @@ -0,0 +1,50 @@ +import { invoke as tauriInvoke } from "@tauri-apps/api/core"; + +export type GooseUpdateStatus = + | { + status: "up_to_date"; + installedVersion: string; + latestVersion: string; + } + | { + status: "update_available"; + installedVersion: string; + latestVersion: string; + }; + +export type RawGooseUpdateStatus = + | { + status: "up_to_date"; + installed_version: string; + latest_version: string; + } + | { + status: "update_available"; + installed_version: string; + latest_version: string; + }; + +export function fromRawGooseUpdateStatus( + status: RawGooseUpdateStatus, +): GooseUpdateStatus { + return { + status: status.status, + installedVersion: status.installed_version, + latestVersion: status.latest_version, + }; +} + +/** Refresh update availability only after a successful Goose setup command. */ +export function shouldRefreshGooseUpdateStatus( + runtimeId: string, + succeeded: boolean, +): boolean { + return runtimeId === "goose" && succeeded; +} + +export async function checkGooseUpdateStatus(): Promise { + const raw = await tauriInvoke( + "check_goose_update_status", + ); + return fromRawGooseUpdateStatus(raw); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7566c82370..0606e3bf8e 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -59,6 +59,7 @@ import type { RawInstallRuntimeResult, RuntimeFileConfigSubset, } from "@/shared/api/tauri"; +import type { RawGooseUpdateStatus } from "@/shared/api/tauriGooseUpdates"; import { normalizePubkey } from "@/shared/lib/pubkey"; type TestIdentity = { @@ -193,6 +194,8 @@ type E2eConfig = { /** Catalog responses for successive discovery calls. The final response repeats. */ acpRuntimesCatalogSequence?: RawAcpRuntimeCatalogEntry[][]; acpRuntimesDelayMs?: number; + /** Goose update-check responses in call order. The final response repeats. */ + gooseUpdateStatuses?: RawGooseUpdateStatus[]; acpAuthMethods?: Record; acpAuthMethodsErrors?: Record; acpAuthMethodsError?: string; @@ -7047,6 +7050,7 @@ function withMockRuntimeConfigMetadata( } let runtimeCatalogDiscoveryCount = 0; +let gooseUpdateStatusCallCount = 0; let mockInstallCompleted = false; let mockConnectCompleted = false; @@ -7175,6 +7179,22 @@ async function handleDiscoverAcpRuntimes( ); } +function handleCheckGooseUpdateStatus( + config: E2eConfig | undefined, +): RawGooseUpdateStatus { + const statuses = config?.mock?.gooseUpdateStatuses; + if (!statuses?.length) { + return { + status: "up_to_date", + installed_version: "1.45.0", + latest_version: "1.45.0", + }; + } + const index = Math.min(gooseUpdateStatusCallCount, statuses.length - 1); + gooseUpdateStatusCallCount += 1; + return statuses[index]; +} + async function handleDiscoverAcpAuthMethods( args: { runtimeId?: string }, config: E2eConfig | undefined, @@ -10636,6 +10656,8 @@ export function maybeInstallE2eTauriMocks() { return activeConfig?.mock?.relayRequiresMembership ?? false; case "discover_acp_providers": return handleDiscoverAcpRuntimes(activeConfig); + case "check_goose_update_status": + return handleCheckGooseUpdateStatus(activeConfig); case "save_custom_harness": return handleSaveCustomHarness( payload as Parameters[0], diff --git a/desktop/tests/e2e/goose-update-screenshots.spec.ts b/desktop/tests/e2e/goose-update-screenshots.spec.ts new file mode 100644 index 0000000000..224bf2de28 --- /dev/null +++ b/desktop/tests/e2e/goose-update-screenshots.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; + +const SHOTS = "test-results/goose-update"; + +async function openGooseRuntimeRow(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + const row = page.getByTestId("doctor-runtime-goose"); + await expect(row).toBeVisible(); + await expect(page.getByTestId("doctor-runtime-ready-goose")).toBeVisible(); + return row; +} + +test("latest Goose shows Ready without an Update action", async ({ page }) => { + await installMockBridge(page, { + gooseUpdateStatuses: [ + { + status: "up_to_date", + installed_version: "1.45.0", + latest_version: "1.45.0", + }, + ], + }); + + const row = await openGooseRuntimeRow(page); + await expect(page.getByTestId("doctor-runtime-install-goose")).toHaveCount(0); + await waitForAnimations(page); + await row.screenshot({ path: `${SHOTS}/goose-current.png` }); +}); + +test("older Goose shows Update and rechecks after success", async ({ + page, +}) => { + await installMockBridge(page, { + gooseUpdateStatuses: [ + { + status: "update_available", + installed_version: "1.44.0", + latest_version: "1.45.0", + }, + { + status: "up_to_date", + installed_version: "1.45.0", + latest_version: "1.45.0", + }, + ], + }); + + const row = await openGooseRuntimeRow(page); + const update = page.getByTestId("doctor-runtime-install-goose"); + await expect(update).toHaveText("Update"); + await waitForAnimations(page); + await row.screenshot({ path: `${SHOTS}/goose-update-available.png` }); + + await update.click(); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "check_goose_update_status", + ).length ?? 0, + ), + ) + .toBe(2); + await expect(update).toHaveCount(0); + await expect(page.getByTestId("doctor-runtime-ready-goose")).toBeVisible(); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index c3473ae4f1..3e26400bcc 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -179,6 +179,12 @@ type MockBridgeOptions = { /** Catalog responses for successive discovery calls. The final response repeats. */ acpRuntimesCatalogSequence?: Record[][]; acpRuntimesDelayMs?: number; + /** Goose update-check responses in call order. The final response repeats. */ + gooseUpdateStatuses?: Array<{ + status: "up_to_date" | "update_available"; + installed_version: string; + latest_version: string; + }>; acpAuthMethods?: Record[] }>; acpAuthMethodsError?: string; /** When set, the `delete_custom_harness` mock command throws with this message. */