mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
fix: fall back to build-from-source when extension download fails (#312)
* fix: fall back to build-from-source when extension download fails Extension manifests hardcode GitHub release URLs for WASM artifacts, but these artifacts are not yet published to any release. This causes all WASM extension installs to fail with HTTP 404. Add a fallback_source field to RegistryEntry so that when the primary WasmDownload source fails (e.g., 404), the installer automatically falls back to WasmBuildable (build from source). The manifest conversion now populates this fallback whenever a download URL is set. Fixes nearai/ironclaw#298 Co-Authored-By: Claude Opus 4.6 <[email protected]> * Address Copilot/Gemini review feedback - Skip fallback for AlreadyInstalled errors (Gemini) - Include both primary and fallback errors in combined message (Copilot) - Fix comment to match broader behavior (any error, not just download) (Copilot) Co-Authored-By: Claude Opus 4.6 <[email protected]> * Address serrrfirat review feedback - Forward AlreadyInstalled from fallback directly instead of wrapping in ExtensionError::Other (defensive, prevents misleading error message) Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add unit tests for fallback install logic Extract fallback_decision() and combine_install_errors() from install_from_entry() to enable direct unit testing without requiring a full ExtensionManager setup. Tests cover: - Primary success returns directly (no fallback attempted) - AlreadyInstalled short-circuits (no fallback attempted) - Download failure with fallback available triggers fallback - Error without fallback source returns primary error - Both-fail produces combined error with both messages - AlreadyInstalled from fallback is forwarded directly Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: firat.sertgoz <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
firat.sertgoz
parent
ebb4ce95e3
commit
f4ba85ffa2
+166
-7
@@ -545,10 +545,41 @@ impl ExtensionManager {
|
||||
async fn install_from_entry(
|
||||
&self,
|
||||
entry: &RegistryEntry,
|
||||
) -> Result<InstallResult, ExtensionError> {
|
||||
let primary_result = self.try_install_from_source(entry, &entry.source).await;
|
||||
match fallback_decision(&primary_result, &entry.fallback_source) {
|
||||
FallbackDecision::Return => primary_result,
|
||||
FallbackDecision::TryFallback => {
|
||||
let primary_err = primary_result.unwrap_err();
|
||||
let fallback = entry.fallback_source.as_ref().unwrap();
|
||||
tracing::info!(
|
||||
extension = %entry.name,
|
||||
primary_error = %primary_err,
|
||||
"Primary install failed, trying fallback source"
|
||||
);
|
||||
self.try_install_from_source(entry, fallback)
|
||||
.await
|
||||
.map_err(|fallback_err| {
|
||||
tracing::error!(
|
||||
extension = %entry.name,
|
||||
fallback_error = %fallback_err,
|
||||
"Fallback install also failed"
|
||||
);
|
||||
combine_install_errors(&primary_err, fallback_err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to install an extension using a specific source.
|
||||
async fn try_install_from_source(
|
||||
&self,
|
||||
entry: &RegistryEntry,
|
||||
source: &ExtensionSource,
|
||||
) -> Result<InstallResult, ExtensionError> {
|
||||
match entry.kind {
|
||||
ExtensionKind::McpServer => {
|
||||
let url = match &entry.source {
|
||||
let url = match source {
|
||||
ExtensionSource::McpUrl { url } => url.clone(),
|
||||
ExtensionSource::Discovered { url } => url.clone(),
|
||||
_ => {
|
||||
@@ -559,7 +590,7 @@ impl ExtensionManager {
|
||||
};
|
||||
self.install_mcp_from_url(&entry.name, &url).await
|
||||
}
|
||||
ExtensionKind::WasmTool => match &entry.source {
|
||||
ExtensionKind::WasmTool => match source {
|
||||
ExtensionSource::WasmDownload {
|
||||
wasm_url,
|
||||
capabilities_url,
|
||||
@@ -586,10 +617,10 @@ impl ExtensionManager {
|
||||
.await
|
||||
}
|
||||
_ => Err(ExtensionError::InstallFailed(
|
||||
"WASM tool entry has no download URL".to_string(),
|
||||
"WASM tool entry has no download URL or build info".to_string(),
|
||||
)),
|
||||
},
|
||||
ExtensionKind::WasmChannel => match &entry.source {
|
||||
ExtensionKind::WasmChannel => match source {
|
||||
ExtensionSource::WasmDownload {
|
||||
wasm_url,
|
||||
capabilities_url,
|
||||
@@ -616,7 +647,7 @@ impl ExtensionManager {
|
||||
.await
|
||||
}
|
||||
_ => Err(ExtensionError::InstallFailed(
|
||||
"WASM channel entry has no download URL".to_string(),
|
||||
"WASM channel entry has no download URL or build info".to_string(),
|
||||
)),
|
||||
},
|
||||
}
|
||||
@@ -2228,10 +2259,56 @@ fn infer_kind_from_url(url: &str) -> ExtensionKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decision from `fallback_decision`: should we try the fallback source or
|
||||
/// return the primary result as-is?
|
||||
enum FallbackDecision {
|
||||
/// Return the primary result directly (success or non-retriable error).
|
||||
Return,
|
||||
/// Primary failed with a retriable error and a fallback source is available.
|
||||
TryFallback,
|
||||
}
|
||||
|
||||
/// Decide whether to attempt a fallback install based on the primary result
|
||||
/// and the availability of a fallback source.
|
||||
fn fallback_decision(
|
||||
primary_result: &Result<InstallResult, ExtensionError>,
|
||||
fallback_source: &Option<Box<ExtensionSource>>,
|
||||
) -> FallbackDecision {
|
||||
match (primary_result, fallback_source) {
|
||||
// Success — no fallback needed
|
||||
(Ok(_), _) => FallbackDecision::Return,
|
||||
// AlreadyInstalled — don't try building from source
|
||||
(Err(ExtensionError::AlreadyInstalled(_)), _) => FallbackDecision::Return,
|
||||
// Failed with a fallback available — try it
|
||||
(Err(_), Some(_)) => FallbackDecision::TryFallback,
|
||||
// Failed with no fallback — return the error
|
||||
(Err(_), None) => FallbackDecision::Return,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combine primary and fallback errors into a single error.
|
||||
///
|
||||
/// Preserves `AlreadyInstalled` from the fallback directly; otherwise wraps
|
||||
/// both error messages into `ExtensionError::Other`.
|
||||
fn combine_install_errors(
|
||||
primary_err: &ExtensionError,
|
||||
fallback_err: ExtensionError,
|
||||
) -> ExtensionError {
|
||||
if matches!(fallback_err, ExtensionError::AlreadyInstalled(_)) {
|
||||
return fallback_err;
|
||||
}
|
||||
ExtensionError::Other(format!(
|
||||
"Primary install failed: {}; fallback install also failed: {}",
|
||||
primary_err, fallback_err
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::extensions::ExtensionKind;
|
||||
use crate::extensions::manager::infer_kind_from_url;
|
||||
use crate::extensions::manager::{
|
||||
FallbackDecision, combine_install_errors, fallback_decision, infer_kind_from_url,
|
||||
};
|
||||
use crate::extensions::{ExtensionError, ExtensionKind, ExtensionSource, InstallResult};
|
||||
|
||||
#[test]
|
||||
fn test_infer_kind_from_url() {
|
||||
@@ -2252,4 +2329,86 @@ mod tests {
|
||||
ExtensionKind::McpServer
|
||||
);
|
||||
}
|
||||
|
||||
// ---- fallback install logic tests ----
|
||||
|
||||
fn make_ok_result() -> Result<InstallResult, ExtensionError> {
|
||||
Ok(InstallResult {
|
||||
name: "test".to_string(),
|
||||
kind: ExtensionKind::WasmTool,
|
||||
message: "Installed".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn make_fallback_source() -> Option<Box<ExtensionSource>> {
|
||||
Some(Box::new(ExtensionSource::WasmBuildable {
|
||||
repo_url: "tools-src/test".to_string(),
|
||||
build_dir: Some("tools-src/test".to_string()),
|
||||
crate_name: Some("test-tool".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_decision_success_returns_directly() {
|
||||
let result = make_ok_result();
|
||||
let fallback = make_fallback_source();
|
||||
assert!(matches!(
|
||||
fallback_decision(&result, &fallback),
|
||||
FallbackDecision::Return
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_decision_already_installed_skips_fallback() {
|
||||
let result: Result<InstallResult, ExtensionError> =
|
||||
Err(ExtensionError::AlreadyInstalled("test".to_string()));
|
||||
let fallback = make_fallback_source();
|
||||
assert!(matches!(
|
||||
fallback_decision(&result, &fallback),
|
||||
FallbackDecision::Return
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_decision_download_failed_triggers_fallback() {
|
||||
let result: Result<InstallResult, ExtensionError> =
|
||||
Err(ExtensionError::DownloadFailed("404 Not Found".to_string()));
|
||||
let fallback = make_fallback_source();
|
||||
assert!(matches!(
|
||||
fallback_decision(&result, &fallback),
|
||||
FallbackDecision::TryFallback
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_decision_error_without_fallback_returns() {
|
||||
let result: Result<InstallResult, ExtensionError> =
|
||||
Err(ExtensionError::DownloadFailed("404 Not Found".to_string()));
|
||||
let fallback = None;
|
||||
assert!(matches!(
|
||||
fallback_decision(&result, &fallback),
|
||||
FallbackDecision::Return
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combine_errors_includes_both_messages() {
|
||||
let primary = ExtensionError::DownloadFailed("404 Not Found".to_string());
|
||||
let fallback = ExtensionError::InstallFailed("cargo not found".to_string());
|
||||
let combined = combine_install_errors(&primary, fallback);
|
||||
let msg = combined.to_string();
|
||||
assert!(msg.contains("404 Not Found"), "missing primary: {msg}");
|
||||
assert!(msg.contains("cargo not found"), "missing fallback: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combine_errors_forwards_already_installed_from_fallback() {
|
||||
let primary = ExtensionError::DownloadFailed("404".to_string());
|
||||
let fallback = ExtensionError::AlreadyInstalled("test".to_string());
|
||||
let combined = combine_install_errors(&primary, fallback);
|
||||
assert!(
|
||||
matches!(combined, ExtensionError::AlreadyInstalled(ref name) if name == "test"),
|
||||
"Expected AlreadyInstalled, got: {combined:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user