mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 16:49:34 +00:00
feat: hot-activate WASM channels, channel-first prompts, unified artifact resolution (#297)
* refactor: unify WASM artifact resolution into registry/artifacts.rs Consolidate duplicated WASM find/build/install logic from 5+ files into a single src/registry/artifacts.rs module. This fixes two bugs: - registry/installer.rs now respects CARGO_TARGET_DIR (was hardcoded) - channels/wasm/bundled.rs now searches all WASM triples (was wasip2 only) Also includes: extension manager hot-activation for WASM channels, extension guidance in LLM prompts, channel manager hot-add support, webhook router channel lookup, and minor cleanups. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: send approval prompts as messages on WASM channels (Telegram, Slack) WASM channels mapped ApprovalNeeded status to a typing indicator, so users on Telegram never saw tool approval prompts — the agent got stuck in AwaitingApproval and all subsequent messages failed with "Waiting for approval". - Intercept ApprovalNeeded in WasmChannel::handle_status_update and send the prompt as an actual message via call_on_respond, showing tool name, description, parameters, and yes/no/always instructions - Guard against empty LLM responses after clean_response() strips reasoning_content think-tags (defense-in-depth for reasoning models) - Add reasoning_content fallback to NearAiChatProvider::complete() for consistency with complete_with_tools() - Add debug logging when empty responses are suppressed - Improve error logging for channel respond() failures - Register WASM channel webhook routes before credential checks so platforms don't deactivate webhook URLs with 404s Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR #297 review comments - ChannelManager::add: use async write().await instead of try_write() - resolve_target_dir: resolve relative CARGO_TARGET_DIR against crate_dir - install_wasm_files: log warning on capabilities copy failure - refresh_active_channel: load capabilities file for webhook secret name - activate_wasm_channel: validate name against path traversal - Fix cargo fmt formatting in nearai_chat.rs Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: wire up channel runtime for hot-activation and address PR review round 2 - Wire up set_channel_runtime() in main.rs so hot-activation actually works (with_channel_runtime was never called — hot-activation was dead code) - Change ExtensionManager channel runtime fields to RwLock<Option<...>> interior mutability so set_channel_runtime(&self) works after Arc wrapping - Fix artifact tests to use resolve_target_dir() instead of hardcoding "target/" (breaks when CARGO_TARGET_DIR is set) - Fix bundled.rs build hint: cargo component build (not cargo build --target) - Fix wasm_artifact_path doc: binary_name should not include .wasm extension Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: use char-aware truncation to prevent UTF-8 panic in approval prompt &s[..77] panics on multi-byte UTF-8 (CJK, emoji). Use s.chars().take(77) for safe truncation at character boundaries. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a320f265b3
commit
ea57447649
@@ -0,0 +1,377 @@
|
||||
//! Unified WASM artifact resolution: find, build, and install WASM components.
|
||||
//!
|
||||
//! This module consolidates all WASM artifact logic that was previously duplicated
|
||||
//! across `cli/tool.rs`, `registry/installer.rs`, `extensions/manager.rs`,
|
||||
//! `channels/wasm/bundled.rs`, and `tools/wasm/loader.rs`.
|
||||
//!
|
||||
//! # Functions
|
||||
//!
|
||||
//! - [`resolve_target_dir`] — resolve the cargo target directory for a crate
|
||||
//! - [`find_wasm_artifact`] — find a compiled `.wasm` by crate name across all triples
|
||||
//! - [`find_any_wasm_artifact`] — find any `.wasm` file (fallback when name is unknown)
|
||||
//! - [`build_wasm_component`] — async build via `cargo component build`
|
||||
//! - [`build_wasm_component_sync`] — sync build for CLI use
|
||||
//! - [`install_wasm_files`] — copy `.wasm` + optional `.capabilities.json` to install dir
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tokio::fs;
|
||||
|
||||
/// WASM target triples to search, in priority order.
|
||||
const WASM_TRIPLES: &[&str] = &[
|
||||
"wasm32-wasip1",
|
||||
"wasm32-wasip2",
|
||||
"wasm32-wasi",
|
||||
"wasm32-unknown-unknown",
|
||||
];
|
||||
|
||||
/// Resolve the cargo target directory for a crate.
|
||||
///
|
||||
/// Checks (in order):
|
||||
/// 1. `CARGO_TARGET_DIR` env var (shared target dir)
|
||||
/// 2. `<crate_dir>/target/` (default per-crate layout)
|
||||
pub fn resolve_target_dir(crate_dir: &Path) -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("CARGO_TARGET_DIR") {
|
||||
let p = PathBuf::from(dir);
|
||||
// Resolve relative CARGO_TARGET_DIR against crate_dir
|
||||
if p.is_relative() {
|
||||
return crate_dir.join(p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
crate_dir.join("target")
|
||||
}
|
||||
|
||||
/// Find a compiled WASM artifact by searching across all target triples.
|
||||
///
|
||||
/// Tries exact name match first (with hyphen-to-underscore normalization),
|
||||
/// then falls back to searching in whichever target directory exists.
|
||||
/// `profile` is `"release"` or `"debug"`.
|
||||
pub fn find_wasm_artifact(crate_dir: &Path, crate_name: &str, profile: &str) -> Option<PathBuf> {
|
||||
let target_base = resolve_target_dir(crate_dir);
|
||||
let snake_name = crate_name.replace('-', "_");
|
||||
|
||||
// Try exact name match in each target triple directory
|
||||
for triple in WASM_TRIPLES {
|
||||
let dir = target_base.join(triple).join(profile);
|
||||
let candidates = [
|
||||
dir.join(format!("{}.wasm", crate_name)),
|
||||
dir.join(format!("{}.wasm", snake_name)),
|
||||
];
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Find any `.wasm` file in the target dirs (fallback when crate name is unknown).
|
||||
///
|
||||
/// Returns the first `.wasm` found across target triples.
|
||||
pub fn find_any_wasm_artifact(crate_dir: &Path, profile: &str) -> Option<PathBuf> {
|
||||
let target_base = resolve_target_dir(crate_dir);
|
||||
|
||||
for triple in WASM_TRIPLES {
|
||||
let dir = target_base.join(triple).join(profile);
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|ext| ext == "wasm").unwrap_or(false) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Build a WASM component using `cargo-component` (async).
|
||||
///
|
||||
/// Streams build output to the terminal. Returns the path to the built artifact.
|
||||
pub async fn build_wasm_component(
|
||||
source_dir: &Path,
|
||||
crate_name: &str,
|
||||
release: bool,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
use tokio::process::Command;
|
||||
|
||||
// Check cargo-component availability
|
||||
let check = Command::new("cargo")
|
||||
.args(["component", "--version"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.await;
|
||||
|
||||
if check.is_err() || !check.as_ref().map(|s| s.success()).unwrap_or(false) {
|
||||
anyhow::bail!("cargo-component not found. Install with: cargo install cargo-component");
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.current_dir(source_dir).args(["component", "build"]);
|
||||
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
|
||||
// Use status() with inherited stdio so build output streams to the terminal.
|
||||
let status = cmd.status().await?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("Build failed (exit code: {})", status);
|
||||
}
|
||||
|
||||
let profile = if release { "release" } else { "debug" };
|
||||
let wasm_filename = format!("{}.wasm", crate_name.replace('-', "_"));
|
||||
|
||||
// Look for the specific crate's WASM file across target triples
|
||||
find_wasm_artifact(source_dir, wasm_filename.trim_end_matches(".wasm"), profile)
|
||||
.or_else(|| {
|
||||
// Fall back: search by crate_name directly
|
||||
find_wasm_artifact(source_dir, crate_name, profile)
|
||||
})
|
||||
.or_else(|| find_any_wasm_artifact(source_dir, profile))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Could not find {} in {}/target/*/{}/ after build",
|
||||
wasm_filename,
|
||||
source_dir.display(),
|
||||
profile,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a WASM component using `cargo-component` (sync, for CLI use).
|
||||
///
|
||||
/// Returns the path to the built artifact.
|
||||
pub fn build_wasm_component_sync(source_dir: &Path, release: bool) -> anyhow::Result<PathBuf> {
|
||||
use std::process::Command;
|
||||
|
||||
println!("Building WASM component in {}...", source_dir.display());
|
||||
|
||||
// Check if cargo-component is available
|
||||
let check = Command::new("cargo")
|
||||
.args(["component", "--version"])
|
||||
.output();
|
||||
|
||||
if check.is_err() || !check.as_ref().map(|o| o.status.success()).unwrap_or(false) {
|
||||
anyhow::bail!(
|
||||
"cargo-component not found. Install with: cargo install cargo-component\n\
|
||||
Or use --skip-build with an existing .wasm file."
|
||||
);
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.current_dir(source_dir).args(["component", "build"]);
|
||||
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
|
||||
println!(
|
||||
" Running: cargo component build{}",
|
||||
if release { " --release" } else { "" }
|
||||
);
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Build failed:\n{}", stderr);
|
||||
}
|
||||
|
||||
let profile = if release { "release" } else { "debug" };
|
||||
|
||||
// Find the built artifact
|
||||
find_any_wasm_artifact(source_dir, profile).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No .wasm file found after build in {}/target/*/{}",
|
||||
source_dir.display(),
|
||||
profile,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Copy WASM binary + optional `capabilities.json` sidecar to an install directory.
|
||||
///
|
||||
/// Looks for capabilities files in `source_dir` matching several naming conventions.
|
||||
/// Returns the destination wasm path.
|
||||
pub async fn install_wasm_files(
|
||||
wasm_src: &Path,
|
||||
source_dir: &Path,
|
||||
name: &str,
|
||||
target_dir: &Path,
|
||||
force: bool,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
fs::create_dir_all(target_dir).await?;
|
||||
|
||||
let wasm_dst = target_dir.join(format!("{}.wasm", name));
|
||||
let caps_dst = target_dir.join(format!("{}.capabilities.json", name));
|
||||
|
||||
if wasm_dst.exists() && !force {
|
||||
anyhow::bail!(
|
||||
"Tool '{}' already exists at {}. Use --force to overwrite.",
|
||||
name,
|
||||
wasm_dst.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Copy WASM binary
|
||||
fs::copy(wasm_src, &wasm_dst).await?;
|
||||
|
||||
// Look for capabilities.json sidecar in the source directory
|
||||
let caps_candidates = [
|
||||
source_dir.join(format!("{}.capabilities.json", name)),
|
||||
source_dir.join(format!("{}-tool.capabilities.json", name)),
|
||||
source_dir.join("capabilities.json"),
|
||||
];
|
||||
for caps_src in &caps_candidates {
|
||||
if caps_src.exists() {
|
||||
if let Err(e) = fs::copy(caps_src, &caps_dst).await {
|
||||
tracing::warn!(
|
||||
"Failed to copy capabilities sidecar {} -> {}: {}",
|
||||
caps_src.display(),
|
||||
caps_dst.display(),
|
||||
e,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(wasm_dst)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_target_dir_default() {
|
||||
// When CARGO_TARGET_DIR is not set, should return <crate_dir>/target
|
||||
let dir = Path::new("/some/crate");
|
||||
let result = resolve_target_dir(dir);
|
||||
assert!(result.ends_with("target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_wasm_artifact_not_found() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
assert!(find_wasm_artifact(dir.path(), "nonexistent", "release").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_wasm_artifact_found() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target_base = resolve_target_dir(dir.path());
|
||||
let wasm_dir = target_base.join("wasm32-wasip2/release");
|
||||
std::fs::create_dir_all(&wasm_dir).unwrap();
|
||||
std::fs::File::create(wasm_dir.join("my_tool.wasm")).unwrap();
|
||||
|
||||
let result = find_wasm_artifact(dir.path(), "my_tool", "release");
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().ends_with("my_tool.wasm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_wasm_artifact_hyphen_to_underscore() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target_base = resolve_target_dir(dir.path());
|
||||
let wasm_dir = target_base.join("wasm32-wasip1/release");
|
||||
std::fs::create_dir_all(&wasm_dir).unwrap();
|
||||
std::fs::File::create(wasm_dir.join("my_tool.wasm")).unwrap();
|
||||
|
||||
// Search with hyphens, should find underscore version
|
||||
let result = find_wasm_artifact(dir.path(), "my-tool", "release");
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_any_wasm_artifact_found() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target_base = resolve_target_dir(dir.path());
|
||||
let wasm_dir = target_base.join("wasm32-wasip2/release");
|
||||
std::fs::create_dir_all(&wasm_dir).unwrap();
|
||||
std::fs::File::create(wasm_dir.join("something.wasm")).unwrap();
|
||||
|
||||
let result = find_any_wasm_artifact(dir.path(), "release");
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_any_wasm_artifact_not_found() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
assert!(find_any_wasm_artifact(dir.path(), "release").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_wasm_files_copies() {
|
||||
let src_dir = TempDir::new().unwrap();
|
||||
let target_dir = TempDir::new().unwrap();
|
||||
|
||||
let wasm_src = src_dir.path().join("test.wasm");
|
||||
tokio::fs::write(&wasm_src, b"\0asm\x01\x00\x00\x00")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create a capabilities file
|
||||
let caps_src = src_dir.path().join("mytool.capabilities.json");
|
||||
tokio::fs::write(&caps_src, b"{}").await.unwrap();
|
||||
|
||||
let result = install_wasm_files(
|
||||
&wasm_src,
|
||||
src_dir.path(),
|
||||
"mytool",
|
||||
target_dir.path(),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let wasm_dst = result.unwrap();
|
||||
assert!(wasm_dst.exists());
|
||||
assert!(target_dir.path().join("mytool.capabilities.json").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_wasm_files_refuses_overwrite() {
|
||||
let src_dir = TempDir::new().unwrap();
|
||||
let target_dir = TempDir::new().unwrap();
|
||||
|
||||
let wasm_src = src_dir.path().join("test.wasm");
|
||||
tokio::fs::write(&wasm_src, b"\0asm").await.unwrap();
|
||||
|
||||
// Pre-create the target
|
||||
let existing = target_dir.path().join("mytool.wasm");
|
||||
tokio::fs::write(&existing, b"existing").await.unwrap();
|
||||
|
||||
let result = install_wasm_files(
|
||||
&wasm_src,
|
||||
src_dir.path(),
|
||||
"mytool",
|
||||
target_dir.path(),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wasm_triples_order() {
|
||||
// Verify the order is as documented
|
||||
assert_eq!(WASM_TRIPLES[0], "wasm32-wasip1");
|
||||
assert_eq!(WASM_TRIPLES[1], "wasm32-wasip2");
|
||||
assert_eq!(WASM_TRIPLES[2], "wasm32-wasi");
|
||||
assert_eq!(WASM_TRIPLES[3], "wasm32-unknown-unknown");
|
||||
}
|
||||
}
|
||||
@@ -94,12 +94,13 @@ impl RegistryInstaller {
|
||||
source_dir.display()
|
||||
);
|
||||
let crate_name = &manifest.source.crate_name;
|
||||
let wasm_path = build_wasm_component(&source_dir, crate_name)
|
||||
.await
|
||||
.map_err(|e| RegistryError::ManifestRead {
|
||||
path: source_dir.clone(),
|
||||
reason: format!("build failed: {}", e),
|
||||
})?;
|
||||
let wasm_path =
|
||||
crate::registry::artifacts::build_wasm_component(&source_dir, crate_name, true)
|
||||
.await
|
||||
.map_err(|e| RegistryError::ManifestRead {
|
||||
path: source_dir.clone(),
|
||||
reason: format!("build failed: {}", e),
|
||||
})?;
|
||||
|
||||
// Copy WASM binary
|
||||
println!(" Installing to {}", target_wasm.display());
|
||||
@@ -353,64 +354,6 @@ impl RegistryInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a WASM component from a source directory using `cargo component build --release`.
|
||||
///
|
||||
/// Uses `tokio::process::Command` with inherited stdio so build progress is visible.
|
||||
/// Looks for the specific `{crate_name}.wasm` in the release directory rather than
|
||||
/// picking the first `.wasm` file found.
|
||||
async fn build_wasm_component(source_dir: &Path, crate_name: &str) -> anyhow::Result<PathBuf> {
|
||||
use tokio::process::Command;
|
||||
|
||||
// Check cargo-component availability
|
||||
let check = Command::new("cargo")
|
||||
.args(["component", "--version"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.await;
|
||||
|
||||
if check.is_err() || !check.as_ref().map(|s| s.success()).unwrap_or(false) {
|
||||
anyhow::bail!("cargo-component not found. Install with: cargo install cargo-component");
|
||||
}
|
||||
|
||||
// Use status() with inherited stdio so build output streams to the terminal.
|
||||
let status = Command::new("cargo")
|
||||
.current_dir(source_dir)
|
||||
.args(["component", "build", "--release"])
|
||||
.status()
|
||||
.await?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("Build failed (exit code: {})", status);
|
||||
}
|
||||
|
||||
// Look for the specific crate's WASM file (Cargo uses underscores in artifact names).
|
||||
let wasm_filename = format!("{}.wasm", crate_name.replace('-', "_"));
|
||||
let target_base = source_dir.join("target");
|
||||
let candidates = [
|
||||
"wasm32-wasip1",
|
||||
"wasm32-wasip2",
|
||||
"wasm32-wasi",
|
||||
"wasm32-unknown-unknown",
|
||||
];
|
||||
|
||||
for target in &candidates {
|
||||
let wasm_path = target_base
|
||||
.join(target)
|
||||
.join("release")
|
||||
.join(&wasm_filename);
|
||||
if wasm_path.exists() {
|
||||
return Ok(wasm_path);
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"Could not find {} in {}/target/*/release/",
|
||||
wasm_filename,
|
||||
source_dir.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// Download an artifact from a URL.
|
||||
async fn download_artifact(url: &str) -> Result<bytes::Bytes, RegistryError> {
|
||||
let response = reqwest::get(url)
|
||||
|
||||
@@ -165,12 +165,14 @@ impl ExtensionManifest {
|
||||
ExtensionSource::WasmBuildable {
|
||||
repo_url: self.source.dir.clone(),
|
||||
build_dir: Some(self.source.dir.clone()),
|
||||
crate_name: Some(self.source.crate_name.clone()),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ExtensionSource::WasmBuildable {
|
||||
repo_url: self.source.dir.clone(),
|
||||
build_dir: Some(self.source.dir.clone()),
|
||||
crate_name: Some(self.source.crate_name.clone()),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! └── _bundles.json <- Bundle definitions (google, messaging, default)
|
||||
//! ```
|
||||
|
||||
pub mod artifacts;
|
||||
pub mod catalog;
|
||||
pub mod embedded;
|
||||
pub mod installer;
|
||||
|
||||
Reference in New Issue
Block a user