mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +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
+9
-166
@@ -4,7 +4,6 @@
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command as ProcessCommand;
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Subcommand;
|
||||
@@ -155,11 +154,18 @@ async fn install_tool(
|
||||
};
|
||||
|
||||
// Build the WASM component if not skipping
|
||||
let profile = if release { "release" } else { "debug" };
|
||||
let wasm_path = if skip_build {
|
||||
// Look for existing wasm file
|
||||
find_wasm_artifact(&path, &tool_name, release)?
|
||||
crate::registry::artifacts::find_wasm_artifact(&path, &tool_name, profile)
|
||||
.or_else(|| crate::registry::artifacts::find_any_wasm_artifact(&path, profile))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No .wasm artifact found. Run without --skip-build to build first."
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
build_wasm_component(&path, release)?
|
||||
crate::registry::artifacts::build_wasm_component_sync(&path, release)?
|
||||
};
|
||||
|
||||
// Look for capabilities file
|
||||
@@ -253,169 +259,6 @@ async fn install_tool(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a WASM component using cargo-component.
|
||||
fn build_wasm_component(source_dir: &Path, release: bool) -> anyhow::Result<PathBuf> {
|
||||
println!("Building WASM component in {}...", source_dir.display());
|
||||
|
||||
// Check if cargo-component is available
|
||||
let check = ProcessCommand::new("cargo")
|
||||
.args(["component", "--version"])
|
||||
.output();
|
||||
|
||||
if check.is_err() || !check.unwrap().status.success() {
|
||||
anyhow::bail!(
|
||||
"cargo-component not found. Install with: cargo install cargo-component\n\
|
||||
Or use --skip-build with an existing .wasm file."
|
||||
);
|
||||
}
|
||||
|
||||
// Build command
|
||||
let mut cmd = ProcessCommand::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);
|
||||
}
|
||||
|
||||
// Find the output wasm file
|
||||
// cargo-component may output to wasm32-wasip1 or wasm32-wasip2 depending on version
|
||||
let profile = if release { "release" } else { "debug" };
|
||||
let candidates = [
|
||||
source_dir
|
||||
.join("target")
|
||||
.join("wasm32-wasip1")
|
||||
.join(profile),
|
||||
source_dir
|
||||
.join("target")
|
||||
.join("wasm32-wasip2")
|
||||
.join(profile),
|
||||
source_dir
|
||||
.join("target")
|
||||
.join("wasm32-unknown-unknown")
|
||||
.join(profile),
|
||||
];
|
||||
|
||||
let target_dir = candidates.iter().find(|p| p.exists()).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No WASM target directory found. Expected one of: {}",
|
||||
candidates
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
// Look for .wasm files in target dir
|
||||
let entries: Vec<_> = std::fs::read_dir(target_dir)?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.map(|ext| ext == "wasm")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if entries.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No .wasm file found in {}. Build may have failed.",
|
||||
target_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
if entries.len() > 1 {
|
||||
println!(
|
||||
" Warning: Multiple .wasm files found, using first: {}",
|
||||
entries[0].path().display()
|
||||
);
|
||||
}
|
||||
|
||||
let wasm_path = entries[0].path();
|
||||
println!(" Built: {}", wasm_path.display());
|
||||
|
||||
Ok(wasm_path)
|
||||
}
|
||||
|
||||
/// Find an existing WASM artifact without building.
|
||||
fn find_wasm_artifact(source_dir: &Path, name: &str, release: bool) -> anyhow::Result<PathBuf> {
|
||||
let profile = if release { "release" } else { "debug" };
|
||||
|
||||
// cargo-component may output to wasm32-wasip1 or wasm32-wasip2 depending on version
|
||||
let target_dirs = [
|
||||
source_dir
|
||||
.join("target")
|
||||
.join("wasm32-wasip1")
|
||||
.join(profile),
|
||||
source_dir
|
||||
.join("target")
|
||||
.join("wasm32-wasip2")
|
||||
.join(profile),
|
||||
source_dir
|
||||
.join("target")
|
||||
.join("wasm32-unknown-unknown")
|
||||
.join(profile),
|
||||
];
|
||||
|
||||
let snake_name = name.replace('-', "_");
|
||||
|
||||
// Try exact name match in any target dir first
|
||||
for target_dir in &target_dirs {
|
||||
let candidates = [
|
||||
target_dir.join(format!("{}.wasm", name)),
|
||||
target_dir.join(format!("{}.wasm", snake_name)),
|
||||
];
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return Ok(candidate.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find a target dir that exists
|
||||
let target_dir = target_dirs.iter().find(|p| p.exists()).ok_or_else(|| {
|
||||
anyhow::anyhow!("No target directory found. Run without --skip-build to build first.")
|
||||
})?;
|
||||
|
||||
// Fall back to any .wasm file
|
||||
let entries: Vec<_> = std::fs::read_dir(target_dir)
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Target directory not found: {}. Run without --skip-build.",
|
||||
target_dir.display()
|
||||
)
|
||||
})?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.map(|ext| ext == "wasm")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if entries.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No .wasm file found in {}. Build the project first or remove --skip-build.",
|
||||
target_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(entries[0].path())
|
||||
}
|
||||
|
||||
/// Extract crate name from Cargo.toml.
|
||||
async fn extract_crate_name(cargo_toml: &Path) -> anyhow::Result<String> {
|
||||
let content = fs::read_to_string(cargo_toml).await?;
|
||||
|
||||
Reference in New Issue
Block a user