mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 07:20:19 +00:00
* Orchestrating jobs and running them in sandboxes * Fix heartbeat: dynamic max_tokens, empty content guard, notification fallback - Query /v1/models API for context_length and set max_tokens to half (floor 4096) instead of hardcoded 1024; reasoning models like GLM-4.7 need much larger budgets - Guard against empty LLM content (reasoning models can burn all tokens on chain-of-thought and return content: null) - Simplify notification routing: try configured channel first, fall back to broadcast_all so heartbeat alerts always reach someone - Add ModelMetadata struct and model_metadata() to LlmProvider trait - Refactor NearAiChatProvider::list_models into shared fetch_models() - Add standalone test_heartbeat example for isolated debugging Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add job detail view with drill-down from jobs list Click a job row to see full details across four sub-tabs: Overview (metadata grid, description, state transitions timeline), Actions (expandable tool call cards with input/output JSON), Thinking (conversation messages styled by role), and Files (embedded workspace tree browser). Co-Authored-By: Claude Opus 4.6 <[email protected]> * Strip model-internal XML tags from LLM responses, fix Telegram parse_mode 400 Some models (GLM-4.7, etc.) emit <tool_call>tool_list</tool_call> in the content field instead of using the OpenAI tool_calls array. This XML leaks through to channels as text, and Telegram's Markdown parser chokes on the underscores, returning 400 "can't parse entities". Two fixes: - Generalize clean_response() to strip <tool_call>, <function_call>, <tool_calls>, and pipe-delimited variants (<|tool_call|>) alongside the existing <thinking> tag stripping - Add Telegram send_message helper with parse_mode fallback: try Markdown first, retry as plain text on "can't parse entities" 400 errors Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add SystemCommand submission type for thread-state-independent commands System commands (/help, /model, /version, /tools, /ping, /debug) now bypass thread-state checks and safety validation via a dedicated Submission::SystemCommand variant. Previously these flowed through process_user_input() which blocked them during Processing/AwaitingApproval /Completed states. - Add /model [name] for runtime model switching with provider validation - Add active_model_name()/set_model() to LlmProvider trait with RwLock hot-swap in both NEAR AI providers - Rewrite /help with aligned columns grouped by category - Expand REPL tab-completion from 10 to 23 slash commands - Remove REPL-local /help interception (now handled by agent) Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add per-tool execution timeouts, auto-create sandbox project dirs, serve built files The sandbox e2e pipeline (agent -> container -> built website -> browsable URL) was broken by three gaps: hardcoded 60s timeouts killed sandbox jobs that need minutes, no auto-created project directory meant container output vanished, and no HTTP route to browse the built files. - Add `execution_timeout()` to the `Tool` trait (default 60s), replace all four hardcoded `Duration::from_secs(60)` call sites (agent_loop, worker, scheduler, worker/runtime) with the per-tool value - Override to 660s in `RunInSandboxTool` (10 min polling + 60s buffer) - Auto-create `~/.ironclaw/projects/{uuid}/` when no `project_dir` is specified, so every sandbox job gets a persistent bind mount - Include `project_dir` and `browse_url` in sandbox tool output JSON - Add `/projects/{id}` and `/projects/{id}/{path}` static file serving routes to the web gateway with path traversal protection and MIME type detection - Add `mime_guess` dependency for content-type detection Co-Authored-By: Claude Opus 4.6 <[email protected]> * Apply cargo fmt to wizard.rs after merge Co-Authored-By: Claude Opus 4.6 <[email protected]> * Persist sandbox jobs in DB, fix web UI, unify job model Sandbox container jobs were invisible to the web UI because they lived only in ContainerJobManager's in-memory HashMap while the API queried ContextManager. This persists them to the agent_jobs table and fixes all six front-end bugs (empty job list, broken back button, empty actions/thinking tabs, wrong files tab, stuck status, no persistence). Key changes: - V4 migration adds project_dir and user_id columns to agent_jobs - Embedded migrations via refinery (no external CLI needed) - SandboxJobRecord CRUD in Store with fire-and-forget DB writes - Unified job_id: sandbox tool generates UUID, passes to ContainerJobManager - Web API queries DB for sandbox jobs, merges with ContextManager direct jobs - New endpoints: restart, project file list/read with path traversal protection - Front-end: rebuild DOM on back navigation, sandbox-aware tabs, job cards in chat stream, source badges, restart button for failed/interrupted jobs - Gateway defaults to enabled, prints Web UI URL on startup - Stale jobs marked "interrupted" on restart for visibility and restartability Co-Authored-By: Claude Opus 4.6 <[email protected]> * Secure in-chat auth: tokens never touch the LLM or chat history Remove the token parameter from tool_auth so the LLM cannot pass raw API keys. Add dedicated REST (POST /api/chat/auth-token) and WebSocket (auth_token) endpoints that route tokens directly to ext_mgr.auth(), completely bypassing the message pipeline, turns, history, and compaction. Web UI shows an auth card (password input + OAuth button) when the agent enters auth mode, submitted via the dedicated endpoint. CLI auth mode interception is unchanged (already secure). New StatusUpdate::AuthRequired/AuthCompleted variants propagate through all channels (SSE, WebSocket, REPL, WASM). Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Add Claude Code mode for sandbox jobs Run Claude Code CLI inside Docker containers as an alternative to the standard worker mode. The bridge spawns `claude -p` with stream-json output, posts events to the orchestrator, and supports follow-up prompts via `--resume`. Key additions: - `claude-bridge` CLI subcommand and ClaudeBridgeRuntime - JobMode enum (Worker vs ClaudeCode) with per-mode container config - Orchestrator endpoints for Claude events and prompt polling - SSE event variants for real-time Claude Code streaming to frontend - Claude Code sub-tab in web UI with terminal-style output and input bar - Database migration for job_mode column and claude_code_events table - ClaudeCodeConfig with env var support (CLAUDE_CODE_ENABLED, etc.) - Mode parameter on run_in_sandbox tool schema Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Skip create_job tool when sandbox is enabled to prevent duplicate jobs When sandbox mode is on, the LLM would call create_job (creating a pending "direct" entry) then run_in_sandbox (creating a second "sandbox" entry), producing two jobs in the list for a single user request. Now register_job_tools() skips create_job when sandbox is enabled since run_in_sandbox already creates tracked jobs. Also improved the run_in_sandbox description to guide the LLM to use it directly and to mention wait=false for async execution. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Web gateway UI quality-of-life improvements Phase 1: Send button disabled state to prevent double-sends, copy button on code blocks, confirm() guards on destructive actions, SSE-driven job list auto-refresh, log filters re-applied on tab switch, jobEvents memory leak fix (cap at 500, cleanup after 60s). Phase 2: Toast notification system replacing chat-based system messages, memory search highlighting with centered snippets, keyboard shortcuts (Ctrl+1-5 tabs, Ctrl+K focus, Ctrl+N new thread, Escape close/blur), activity tab toolbar with event type filter and auto-scroll toggle. Phase 3: Thread sidebar with load/switch/create, thread_id passed with messages, collapsible to hamburger. Memory inline editing with textarea, Save/Cancel, POST to /api/memory/write. Phase 4: Gateway status popover on hover (polls every 30s), extension install form (name/URL/kind), markdown rendering in memory viewer for .md files, mobile responsive layout at 768px breakpoint. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Add routines system, remove non-sandbox job mode from web UI Routines: scheduled & reactive job system with cron and event triggers, lightweight (single LLM call) and full-job execution modes, guardrails (cooldown, max concurrent, dedup), and LLM-facing tools for CRUD. Web UI: remove ContextManager-backed "direct" job mode entirely. Jobs are now exclusively sandbox-backed (DB + container). Simplify job detail response, drop dead types (ActionInfo, MessageInfo, MessageToolCallInfo), fix Browse Files CSS loading (trailing-slash redirect), fix Activity tab event rendering. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Re-enable chat input on agent completion, auto-auth on tool_activate, recover tool calls from content XML Three fixes: 1. Chat input stays disabled after agent finishes: the "Done" status SSE event now calls enableChatInput() as a safety net when the response event is empty or lost. Same for auth_completed and cancelAuth(). 2. tool_activate never triggers auth: when activation fails due to missing authentication, it now auto-initiates the auth flow (same pattern as the web API handler). detect_auth_awaiting() also matches tool_activate results now. 3. Models like GLM-4.7 emit tool calls as XML tags in content (<tool_call>tool_list</tool_call>) instead of using the structured tool_calls array. recover_tool_calls_from_content() extracts and validates these before falling back to plain text. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Add routines web UI tab, update docs for sandbox-jobs branch Add full routines management to the web gateway (list, detail, trigger, toggle, delete) with 7 new API endpoints, response types, and frontend (HTML, JS, CSS). Update FEATURE_PARITY.md (~23 rows), CLAUDE.md (new subsystems, config, TODOs), and README.md (architecture diagram, features, components, fix onboard command). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Bind Telegram bot to owner account during setup Without owner binding, anyone who discovers the bot can send it messages. The setup wizard now prompts the user to message their bot, captures their Telegram user ID via getUpdates, and persists it as telegram_owner_id in settings. On startup, the owner_id is injected into the WASM channel config so the existing owner restriction logic drops messages from non-owners. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Move settings from disk to PostgreSQL database Settings previously lived in three JSON files on disk (settings.json, mcp-servers.json, session.json). This made them inaccessible from the web UI and caused redundant disk reads (Settings::load() called 8+ times during startup). Now all settings live in a `settings` table (user_id + key -> JSONB) with only 4 bootstrap fields remaining on disk (database_url, pool size, secrets key source, onboard_completed) since they're needed before the DB connection exists. - Add V8 migration for settings table - Add BootstrapConfig (thin disk file) and Settings DB round-trip - Add Store CRUD methods for settings (get/set/delete/list/bulk) - Refactor Config to load from DB (env > DB > default cascade) - Add SessionManager DB persistence for session tokens - Add DB-backed MCP server config load/save functions - Add 6 settings web API endpoints (list/get/set/delete/export/import) - Add one-time disk-to-DB migration on first boot - Make CLI config commands async with DB access (disk fallback) Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Seed workspace on boot, fix gateway duplicate logs and URL auto-auth - Add Workspace::seed_if_empty() to create core identity files (README, MEMORY, IDENTITY, SOUL, AGENTS, USER, HEARTBEAT) when missing, called on every boot without overwriting existing user edits - Remove duplicate gateway log lines from web/mod.rs (main.rs has the useful clickable ?token= URL) - Auto-authenticate from ?token= URL parameter in the web UI and strip the token from the address bar after successful auth Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Harden sandbox security (path traversal + orchestrator auth) Two vulnerabilities fixed: 1. project_dir path traversal: The create_job tool let the LLM specify arbitrary host paths for Docker bind mounts. Removed project_dir from the tool schema entirely, and added canonicalization + prefix validation at both resolve_project_dir() and the job_manager bind mount point. 2. Orchestrator API auth bypass: worker_auth_middleware was defined but never applied. Each handler manually called validate_token(), so any new endpoint that forgot would be publicly accessible. Applied the middleware as route_layer on all /worker/ routes, removed manual auth from all 7 handlers. Bind to 127.0.0.1 on macOS/Windows (Linux keeps 0.0.0.0 since containers reach host via docker bridge, not loopback). Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Rework gateway chat with pinned assistant, pagination, and NEAR AI response chaining Implements the 4-phase plan for overhauling the web gateway chat: - Phase 1: Pinned "Assistant" thread at top of sidebar, regular threads below - Phase 2: Cursor-based history pagination with infinite scroll - Phase 3: NEAR AI previous_response_id chaining (delta-only messages), with fallback to full history on chain errors, and DB persistence of chain state across restarts - Phase 4: SSE thread isolation (events filtered by thread_id) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Add per-request HTTP timeout to WASM host, redact credentials in errors Three fixes for WASM channel reliability: 1. Per-request timeout: Add optional timeout-ms parameter to http-request in both channel and tool WIT interfaces. Telegram long-poll now specifies 35s (outliving the 30s server-side hold), while regular API calls use the 30s default. Fixes the triple-30s timeout race that caused polling failures. 2. Credential redaction: reqwest::Error includes the full URL (with injected bot tokens) in its Display output. Scrub credential values from error messages before logging or returning to WASM. 3. Webhook route registration: Remove tunnel URL gate so webhook routes are always available when webhook channels exist, not only when TUNNEL_URL is configured. Co-Authored-By: Claude Opus 4.6 <[email protected]> * chore: Fix clippy warnings in WASM tools and channels - slack channel: allow dead_code on signing_secret_name (forward compat field) - gmail tool: use div_ceil() instead of manual (n+2)/3 - google-calendar tool: extract CreateEventParams/UpdateEventParams structs to fix too-many-arguments warnings Co-Authored-By: Claude Opus 4.6 <[email protected]> * Fix approval flow * fix: Rebuild bundled telegram.wasm with updated WIT interface The bundled WASM binary must match the host's WIT definition. Previous binary was compiled against the old 4-arg http-request; this rebuild includes the new timeout-ms parameter. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: Load WASM channels from disk instead of bundling in binary Remove include_bytes! embedding of telegram.wasm. Channels are now loaded from their build output directories (channels-src/<name>/target/) during onboarding, then from ~/.ironclaw/channels/ at runtime. - bundled.rs: locate_channel_artifacts() finds WASM + capabilities from build output; IRONCLAW_CHANNELS_SRC env var overrides the default path - available_channel_names(): only lists channels with build artifacts - bundled_channel_names(): lists all known channels (manifest) - Setup wizard uses available_channel_names() to offer installable channels - Add *.wasm to .gitignore, remove tracked telegram.wasm Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Persist gateway auth token, fix thread hydration race, polish auth screen Three web gateway UX fixes: 1. Token persistence: Store auth token in sessionStorage so refreshing the page doesn't force re-authentication. Hide the auth screen immediately when a saved token exists to prevent flash. 2. Thread hydration: Remove the !msgs.is_empty() bail-out in maybe_hydrate_thread so that even brand-new (empty) assistant threads get hydrated with their correct DB UUID. Previously resolve_thread would mint a fresh UUID, causing messages to land in the wrong conversation and duplicate threads to appear. 3. Auth screen: Redesign as a centered card with brand, tagline, labeled input, and hint text. Also adds 34 new tests covering session/thread lifecycle, thread resolution isolation (user, channel, external ID), hydration edge cases, serialization round-trips, approval flows, and stale mapping recovery. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: Use bindgen! for WASM tool wrapper, add dev tool loading Three changes: 1. Rewrite src/tools/wasm/wrapper.rs to use wasmtime::component::bindgen! instead of manual linker.root().func_wrap(). This fixes the "component imports instance 'near:agent/host', but a matching implementation was not found in the linker" error. All 6 host functions (log, now-millis, workspace-read, http-request, secret-exists, tool-invoke) are now properly registered under the near:agent/host namespace. Also adds WASI support, credential injection, and leak detection for HTTP requests made by WASM tools. 2. Add dev tool loading to src/tools/wasm/loader.rs. During startup, the loader now also scans tools-src/*/target/wasm32-wasip2/release/ for build artifacts that are newer than installed copies. This means during development you just rebuild the WASM and restart the host; no manual copy step needed. Set IRONCLAW_TOOLS_SRC to override the source dir. 3. Wire up load_dev_tools() in main.rs alongside the existing load_from_dir() call. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: Wire main startup and CLI to use DB-backed settings main.rs now reloads Config from the database after connecting, attaches the store to the session manager for dual-write tokens, and loads MCP servers from DB instead of disk. ExtensionManager and MCP CLI commands use DB when available with disk fallback. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
2482 lines
88 KiB
Rust
2482 lines
88 KiB
Rust
//! WASM channel wrapper implementing the Channel trait.
|
|
//!
|
|
//! Wraps a prepared WASM channel module and provides the Channel interface.
|
|
//! Each callback (on_start, on_http_request, on_poll, on_respond) creates
|
|
//! a fresh WASM instance for isolation.
|
|
//!
|
|
//! # Architecture
|
|
//!
|
|
//! ```text
|
|
//! ┌──────────────────────────────────────────────────────────────┐
|
|
//! │ WasmChannel │
|
|
//! │ │
|
|
//! │ ┌─────────────┐ call_on_* ┌──────────────────────┐ │
|
|
//! │ │ Channel │ ────────────> │ execute_callback │ │
|
|
//! │ │ Trait │ │ (fresh instance) │ │
|
|
//! │ └─────────────┘ └──────────┬───────────┘ │
|
|
//! │ │ │
|
|
//! │ ▼ │
|
|
//! │ ┌──────────────────────────────────────────────────────┐ │
|
|
//! │ │ ChannelStoreData │ │
|
|
//! │ │ ┌─────────────┐ ┌──────────────────────────────┐ │ │
|
|
//! │ │ │ limiter │ │ ChannelHostState │ │ │
|
|
//! │ │ └─────────────┘ │ - emitted_messages │ │ │
|
|
//! │ │ │ - pending_writes │ │ │
|
|
//! │ │ │ - base HostState (logging) │ │ │
|
|
//! │ │ └──────────────────────────────┘ │ │
|
|
//! │ └──────────────────────────────────────────────────────┘ │
|
|
//! └──────────────────────────────────────────────────────────────┘
|
|
//! ```
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
use tokio::sync::{RwLock, mpsc, oneshot};
|
|
use tokio_stream::wrappers::ReceiverStream;
|
|
use uuid::Uuid;
|
|
use wasmtime::Store;
|
|
use wasmtime::component::{Component, Linker};
|
|
use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView};
|
|
|
|
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
|
use crate::channels::wasm::error::WasmChannelError;
|
|
use crate::channels::wasm::host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage};
|
|
use crate::channels::wasm::router::RegisteredEndpoint;
|
|
use crate::channels::wasm::runtime::{PreparedChannelModule, WasmChannelRuntime};
|
|
use crate::channels::wasm::schema::ChannelConfig;
|
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
|
use crate::error::ChannelError;
|
|
use crate::safety::LeakDetector;
|
|
use crate::tools::wasm::LogLevel;
|
|
use crate::tools::wasm::WasmResourceLimiter;
|
|
|
|
// Generate component model bindings from the WIT file
|
|
wasmtime::component::bindgen!({
|
|
path: "wit/channel.wit",
|
|
world: "sandboxed-channel",
|
|
async: false,
|
|
with: {
|
|
// Use our own store data type
|
|
},
|
|
});
|
|
|
|
/// Store data for WASM channel execution.
|
|
///
|
|
/// Contains the resource limiter, channel-specific host state, and WASI context.
|
|
struct ChannelStoreData {
|
|
limiter: WasmResourceLimiter,
|
|
host_state: ChannelHostState,
|
|
wasi: WasiCtx,
|
|
table: ResourceTable,
|
|
/// Injected credentials for URL substitution (e.g., bot tokens).
|
|
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
|
|
credentials: HashMap<String, String>,
|
|
}
|
|
|
|
impl ChannelStoreData {
|
|
fn new(
|
|
memory_limit: u64,
|
|
channel_name: &str,
|
|
capabilities: ChannelCapabilities,
|
|
credentials: HashMap<String, String>,
|
|
) -> Self {
|
|
// Create a minimal WASI context (no filesystem, no env vars for security)
|
|
let wasi = WasiCtxBuilder::new().build();
|
|
|
|
Self {
|
|
limiter: WasmResourceLimiter::new(memory_limit),
|
|
host_state: ChannelHostState::new(channel_name, capabilities),
|
|
wasi,
|
|
table: ResourceTable::new(),
|
|
credentials,
|
|
}
|
|
}
|
|
|
|
/// Inject credentials into a string by replacing placeholders.
|
|
///
|
|
/// Replaces patterns like `{TELEGRAM_BOT_TOKEN}` or `{WHATSAPP_ACCESS_TOKEN}`
|
|
/// with actual values from the injected credentials map. This allows WASM
|
|
/// channels to reference credentials without ever seeing the actual values.
|
|
///
|
|
/// Works on URLs, headers, or any string with credential placeholders.
|
|
fn inject_credentials(&self, input: &str, context: &str) -> String {
|
|
let mut result = input.to_string();
|
|
|
|
tracing::debug!(
|
|
input_preview = %input.chars().take(100).collect::<String>(),
|
|
context = %context,
|
|
credential_count = self.credentials.len(),
|
|
credential_names = ?self.credentials.keys().collect::<Vec<_>>(),
|
|
"Injecting credentials"
|
|
);
|
|
|
|
// Replace all known placeholders from the credentials map
|
|
for (name, value) in &self.credentials {
|
|
let placeholder = format!("{{{}}}", name);
|
|
if result.contains(&placeholder) {
|
|
tracing::debug!(
|
|
placeholder = %placeholder,
|
|
context = %context,
|
|
"Found and replacing credential placeholder"
|
|
);
|
|
result = result.replace(&placeholder, value);
|
|
}
|
|
}
|
|
|
|
// Check if any placeholders remain (indicates missing credential)
|
|
if result.contains('{') && result.contains('}') {
|
|
// Only warn if it looks like an unresolved placeholder (not JSON braces)
|
|
let brace_pattern = regex::Regex::new(r"\{[A-Z_]+\}").ok();
|
|
if let Some(re) = brace_pattern {
|
|
if re.is_match(&result) {
|
|
tracing::warn!(
|
|
context = %context,
|
|
"String may contain unresolved credential placeholders"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
/// Replace injected credential values with `[REDACTED]` in text.
|
|
///
|
|
/// Prevents credentials from leaking through error messages, logs, or
|
|
/// return values to WASM. reqwest::Error includes the full URL in its
|
|
/// Display output, so any error from an injected-URL request will
|
|
/// contain the raw credential unless we scrub it.
|
|
fn redact_credentials(&self, text: &str) -> String {
|
|
let mut result = text.to_string();
|
|
for (name, value) in &self.credentials {
|
|
if !value.is_empty() {
|
|
result = result.replace(value, &format!("[REDACTED:{}]", name));
|
|
}
|
|
}
|
|
result
|
|
}
|
|
}
|
|
|
|
// Implement WasiView to provide WASI context and resource table
|
|
impl WasiView for ChannelStoreData {
|
|
fn ctx(&mut self) -> &mut WasiCtx {
|
|
&mut self.wasi
|
|
}
|
|
|
|
fn table(&mut self) -> &mut ResourceTable {
|
|
&mut self.table
|
|
}
|
|
}
|
|
|
|
// Implement the generated Host trait for channel-host interface
|
|
impl near::agent::channel_host::Host for ChannelStoreData {
|
|
fn log(&mut self, level: near::agent::channel_host::LogLevel, message: String) {
|
|
let log_level = match level {
|
|
near::agent::channel_host::LogLevel::Trace => LogLevel::Trace,
|
|
near::agent::channel_host::LogLevel::Debug => LogLevel::Debug,
|
|
near::agent::channel_host::LogLevel::Info => LogLevel::Info,
|
|
near::agent::channel_host::LogLevel::Warn => LogLevel::Warn,
|
|
near::agent::channel_host::LogLevel::Error => LogLevel::Error,
|
|
};
|
|
let _ = self.host_state.log(log_level, message);
|
|
}
|
|
|
|
fn now_millis(&mut self) -> u64 {
|
|
self.host_state.now_millis()
|
|
}
|
|
|
|
fn workspace_read(&mut self, path: String) -> Option<String> {
|
|
self.host_state.workspace_read(&path).ok().flatten()
|
|
}
|
|
|
|
fn workspace_write(&mut self, path: String, content: String) -> Result<(), String> {
|
|
self.host_state
|
|
.workspace_write(&path, content)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn http_request(
|
|
&mut self,
|
|
method: String,
|
|
url: String,
|
|
headers_json: String,
|
|
body: Option<Vec<u8>>,
|
|
timeout_ms: Option<u32>,
|
|
) -> Result<near::agent::channel_host::HttpResponse, String> {
|
|
tracing::info!(
|
|
method = %method,
|
|
original_url = %url,
|
|
body_len = body.as_ref().map(|b| b.len()).unwrap_or(0),
|
|
"WASM http_request called"
|
|
);
|
|
|
|
// Inject credentials into URL (e.g., replace {TELEGRAM_BOT_TOKEN} with actual token)
|
|
let injected_url = self.inject_credentials(&url, "url");
|
|
|
|
// Log whether injection happened (without revealing the token)
|
|
let url_changed = injected_url != url;
|
|
tracing::info!(url_changed = url_changed, "URL after credential injection");
|
|
|
|
// Check if HTTP is allowed for this URL
|
|
self.host_state
|
|
.check_http_allowed(&injected_url, &method)
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "HTTP not allowed");
|
|
format!("HTTP not allowed: {}", e)
|
|
})?;
|
|
|
|
// Record the request for rate limiting
|
|
self.host_state.record_http_request().map_err(|e| {
|
|
tracing::error!(error = %e, "Rate limit exceeded");
|
|
format!("Rate limit exceeded: {}", e)
|
|
})?;
|
|
|
|
// Parse headers and inject credentials into header values
|
|
// This allows patterns like "Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}"
|
|
let raw_headers: std::collections::HashMap<String, String> =
|
|
serde_json::from_str(&headers_json).unwrap_or_default();
|
|
|
|
let headers: std::collections::HashMap<String, String> = raw_headers
|
|
.into_iter()
|
|
.map(|(k, v)| {
|
|
(
|
|
k.clone(),
|
|
self.inject_credentials(&v, &format!("header:{}", k)),
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
let headers_changed = headers
|
|
.values()
|
|
.any(|v| v.contains("Bearer ") && !v.contains('{'));
|
|
tracing::debug!(
|
|
header_count = headers.len(),
|
|
headers_changed = headers_changed,
|
|
"Parsed and injected request headers"
|
|
);
|
|
|
|
let url = injected_url;
|
|
let leak_detector = LeakDetector::new();
|
|
let header_vec: Vec<(String, String)> = headers
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
.collect();
|
|
|
|
leak_detector
|
|
.scan_http_request(&url, &header_vec, body.as_deref())
|
|
.map_err(|e| format!("Potential secret leak blocked: {}", e))?;
|
|
|
|
// Make the HTTP request using blocking I/O
|
|
// We're already in a spawn_blocking context, so we can use block_on
|
|
let result = tokio::runtime::Handle::current().block_on(async {
|
|
let client = reqwest::Client::new();
|
|
|
|
let mut request = match method.to_uppercase().as_str() {
|
|
"GET" => client.get(&url),
|
|
"POST" => client.post(&url),
|
|
"PUT" => client.put(&url),
|
|
"DELETE" => client.delete(&url),
|
|
"PATCH" => client.patch(&url),
|
|
"HEAD" => client.head(&url),
|
|
_ => return Err(format!("Unsupported HTTP method: {}", method)),
|
|
};
|
|
|
|
// Add headers
|
|
for (key, value) in headers {
|
|
request = request.header(&key, &value);
|
|
}
|
|
|
|
// Add body if present
|
|
if let Some(body_bytes) = body {
|
|
request = request.body(body_bytes);
|
|
}
|
|
|
|
// Send request with caller-specified timeout (default 30s).
|
|
// Cap at callback_timeout to prevent outliving the host wrapper.
|
|
let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000) as u64);
|
|
let response = request.timeout(timeout).send().await.map_err(|e| {
|
|
// Walk the full error chain so we get the actual root cause
|
|
// (DNS, TLS, connection refused, etc.) instead of just
|
|
// "error sending request for url (...)".
|
|
let mut chain = format!("HTTP request failed: {}", e);
|
|
let mut source = std::error::Error::source(&e);
|
|
while let Some(cause) = source {
|
|
chain.push_str(&format!(" -> {}", cause));
|
|
source = cause.source();
|
|
}
|
|
chain
|
|
})?;
|
|
|
|
let status = response.status().as_u16();
|
|
let response_headers: std::collections::HashMap<String, String> = response
|
|
.headers()
|
|
.iter()
|
|
.filter_map(|(k, v)| {
|
|
v.to_str()
|
|
.ok()
|
|
.map(|v| (k.as_str().to_string(), v.to_string()))
|
|
})
|
|
.collect();
|
|
let headers_json = serde_json::to_string(&response_headers).unwrap_or_default();
|
|
let body = response
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| format!("Failed to read response body: {}", e))?
|
|
.to_vec();
|
|
|
|
tracing::info!(
|
|
status = status,
|
|
body_len = body.len(),
|
|
"HTTP response received"
|
|
);
|
|
|
|
// Log response body for debugging (truncated at char boundary)
|
|
if let Ok(body_str) = std::str::from_utf8(&body) {
|
|
let truncated = if body_str.chars().count() > 500 {
|
|
format!("{}...", body_str.chars().take(500).collect::<String>())
|
|
} else {
|
|
body_str.to_string()
|
|
};
|
|
tracing::debug!(body = %truncated, "Response body");
|
|
}
|
|
|
|
// Leak detection on response body (best-effort)
|
|
if let Ok(body_str) = std::str::from_utf8(&body) {
|
|
leak_detector
|
|
.scan_and_clean(body_str)
|
|
.map_err(|e| format!("Potential secret leak in response: {}", e))?;
|
|
}
|
|
|
|
Ok(near::agent::channel_host::HttpResponse {
|
|
status,
|
|
headers_json,
|
|
body,
|
|
})
|
|
});
|
|
|
|
// Scrub credential values from error messages before logging or returning
|
|
// to WASM. reqwest::Error includes the full URL (with injected credentials)
|
|
// in its Display output.
|
|
let result = result.map_err(|e| self.redact_credentials(&e));
|
|
|
|
match &result {
|
|
Ok(resp) => {
|
|
tracing::info!(status = resp.status, "http_request completed successfully");
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(error = %e, "http_request failed");
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
fn secret_exists(&mut self, name: String) -> bool {
|
|
self.host_state.secret_exists(&name)
|
|
}
|
|
|
|
fn emit_message(&mut self, msg: near::agent::channel_host::EmittedMessage) {
|
|
tracing::info!(
|
|
user_id = %msg.user_id,
|
|
user_name = ?msg.user_name,
|
|
content_len = msg.content.len(),
|
|
"WASM emit_message called"
|
|
);
|
|
|
|
let mut emitted = EmittedMessage::new(msg.user_id.clone(), msg.content.clone());
|
|
if let Some(name) = msg.user_name {
|
|
emitted = emitted.with_user_name(name);
|
|
}
|
|
if let Some(tid) = msg.thread_id {
|
|
emitted = emitted.with_thread_id(tid);
|
|
}
|
|
emitted = emitted.with_metadata(msg.metadata_json);
|
|
|
|
match self.host_state.emit_message(emitted) {
|
|
Ok(()) => {
|
|
tracing::info!("Message emitted to host state successfully");
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(error = %e, "Failed to emit message to host state");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A WASM-based channel implementing the Channel trait.
|
|
#[allow(dead_code)]
|
|
pub struct WasmChannel {
|
|
/// Channel name.
|
|
name: String,
|
|
|
|
/// Runtime for WASM execution.
|
|
runtime: Arc<WasmChannelRuntime>,
|
|
|
|
/// Prepared module (compiled WASM).
|
|
prepared: Arc<PreparedChannelModule>,
|
|
|
|
/// Channel capabilities.
|
|
capabilities: ChannelCapabilities,
|
|
|
|
/// Channel configuration JSON (passed to on_start).
|
|
/// Wrapped in RwLock to allow updating before start.
|
|
config_json: RwLock<String>,
|
|
|
|
/// Channel configuration returned by on_start.
|
|
channel_config: RwLock<Option<ChannelConfig>>,
|
|
|
|
/// Message sender (for emitting messages to the stream).
|
|
/// Wrapped in Arc for sharing with the polling task.
|
|
message_tx: Arc<RwLock<Option<mpsc::Sender<IncomingMessage>>>>,
|
|
|
|
/// Pending responses (for synchronous response handling).
|
|
pending_responses: RwLock<HashMap<Uuid, oneshot::Sender<String>>>,
|
|
|
|
/// Rate limiter for message emission.
|
|
/// Wrapped in Arc for sharing with the polling task.
|
|
rate_limiter: Arc<RwLock<ChannelEmitRateLimiter>>,
|
|
|
|
/// Shutdown signal sender.
|
|
shutdown_tx: RwLock<Option<oneshot::Sender<()>>>,
|
|
|
|
/// Polling shutdown signal sender (keeps polling alive while held).
|
|
poll_shutdown_tx: RwLock<Option<oneshot::Sender<()>>>,
|
|
|
|
/// Registered HTTP endpoints.
|
|
endpoints: RwLock<Vec<RegisteredEndpoint>>,
|
|
|
|
/// Injected credentials for HTTP requests (e.g., bot tokens).
|
|
/// Keys are placeholder names like "TELEGRAM_BOT_TOKEN".
|
|
/// Wrapped in Arc for sharing with the polling task.
|
|
credentials: Arc<RwLock<HashMap<String, String>>>,
|
|
|
|
/// Background task that repeats typing indicators every 4 seconds.
|
|
/// Telegram's "typing..." indicator expires after ~5s, so we refresh it.
|
|
typing_task: RwLock<Option<tokio::task::JoinHandle<()>>>,
|
|
}
|
|
|
|
impl WasmChannel {
|
|
/// Create a new WASM channel.
|
|
pub fn new(
|
|
runtime: Arc<WasmChannelRuntime>,
|
|
prepared: Arc<PreparedChannelModule>,
|
|
capabilities: ChannelCapabilities,
|
|
config_json: String,
|
|
) -> Self {
|
|
let name = prepared.name.clone();
|
|
let rate_limiter = ChannelEmitRateLimiter::new(capabilities.emit_rate_limit.clone());
|
|
|
|
Self {
|
|
name,
|
|
runtime,
|
|
prepared,
|
|
capabilities,
|
|
config_json: RwLock::new(config_json),
|
|
channel_config: RwLock::new(None),
|
|
message_tx: Arc::new(RwLock::new(None)),
|
|
pending_responses: RwLock::new(HashMap::new()),
|
|
rate_limiter: Arc::new(RwLock::new(rate_limiter)),
|
|
shutdown_tx: RwLock::new(None),
|
|
poll_shutdown_tx: RwLock::new(None),
|
|
endpoints: RwLock::new(Vec::new()),
|
|
credentials: Arc::new(RwLock::new(HashMap::new())),
|
|
typing_task: RwLock::new(None),
|
|
}
|
|
}
|
|
|
|
/// Update the channel config before starting.
|
|
///
|
|
/// Merges the provided values into the existing config JSON.
|
|
/// Call this before `start()` to inject runtime values like tunnel_url.
|
|
pub async fn update_config(&self, updates: HashMap<String, serde_json::Value>) {
|
|
let mut config_guard = self.config_json.write().await;
|
|
|
|
// Parse existing config
|
|
let mut config: HashMap<String, serde_json::Value> =
|
|
serde_json::from_str(&config_guard).unwrap_or_default();
|
|
|
|
// Merge updates
|
|
for (key, value) in updates {
|
|
config.insert(key, value);
|
|
}
|
|
|
|
// Serialize back
|
|
*config_guard = serde_json::to_string(&config).unwrap_or_else(|_| "{}".to_string());
|
|
|
|
tracing::debug!(
|
|
channel = %self.name,
|
|
config = %*config_guard,
|
|
"Updated channel config"
|
|
);
|
|
}
|
|
|
|
/// Set a credential for URL injection.
|
|
pub async fn set_credential(&self, name: &str, value: String) {
|
|
self.credentials
|
|
.write()
|
|
.await
|
|
.insert(name.to_string(), value);
|
|
}
|
|
|
|
/// Get a snapshot of credentials for use in callbacks.
|
|
pub async fn get_credentials(&self) -> HashMap<String, String> {
|
|
self.credentials.read().await.clone()
|
|
}
|
|
|
|
/// Get the channel name.
|
|
pub fn channel_name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
|
|
/// Get the channel capabilities.
|
|
pub fn capabilities(&self) -> &ChannelCapabilities {
|
|
&self.capabilities
|
|
}
|
|
|
|
/// Get the registered endpoints.
|
|
pub async fn endpoints(&self) -> Vec<RegisteredEndpoint> {
|
|
self.endpoints.read().await.clone()
|
|
}
|
|
|
|
/// Add channel host functions to the linker using generated bindings.
|
|
///
|
|
/// Uses the wasmtime::component::bindgen! generated `add_to_linker` function
|
|
/// to properly register all host functions with correct component model signatures.
|
|
fn add_host_functions(linker: &mut Linker<ChannelStoreData>) -> Result<(), WasmChannelError> {
|
|
// Add WASI support (required by the component adapter)
|
|
wasmtime_wasi::add_to_linker_sync(linker).map_err(|e| {
|
|
WasmChannelError::Config(format!("Failed to add WASI functions: {}", e))
|
|
})?;
|
|
|
|
// Use the generated add_to_linker function from bindgen for our custom interface
|
|
near::agent::channel_host::add_to_linker(linker, |state| state).map_err(|e| {
|
|
WasmChannelError::Config(format!("Failed to add host functions: {}", e))
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Create a fresh store configured for WASM execution.
|
|
fn create_store(
|
|
runtime: &WasmChannelRuntime,
|
|
prepared: &PreparedChannelModule,
|
|
capabilities: &ChannelCapabilities,
|
|
credentials: HashMap<String, String>,
|
|
) -> Result<Store<ChannelStoreData>, WasmChannelError> {
|
|
let engine = runtime.engine();
|
|
let limits = &prepared.limits;
|
|
|
|
// Create fresh store with channel state (NEAR pattern: fresh instance per call)
|
|
let store_data = ChannelStoreData::new(
|
|
limits.memory_bytes,
|
|
&prepared.name,
|
|
capabilities.clone(),
|
|
credentials,
|
|
);
|
|
let mut store = Store::new(engine, store_data);
|
|
|
|
// Configure fuel if enabled
|
|
if runtime.config().fuel_config.enabled {
|
|
store
|
|
.set_fuel(limits.fuel)
|
|
.map_err(|e| WasmChannelError::Config(format!("Failed to set fuel: {}", e)))?;
|
|
}
|
|
|
|
// Configure epoch deadline for timeout backup
|
|
store.epoch_deadline_trap();
|
|
store.set_epoch_deadline(1);
|
|
|
|
// Set up resource limiter
|
|
store.limiter(|data| &mut data.limiter);
|
|
|
|
Ok(store)
|
|
}
|
|
|
|
/// Instantiate the WASM component using generated bindings.
|
|
fn instantiate_component(
|
|
runtime: &WasmChannelRuntime,
|
|
prepared: &PreparedChannelModule,
|
|
store: &mut Store<ChannelStoreData>,
|
|
) -> Result<SandboxedChannel, WasmChannelError> {
|
|
let engine = runtime.engine();
|
|
|
|
// Compile the component (uses cached bytes)
|
|
let component = Component::new(engine, prepared.component_bytes())
|
|
.map_err(|e| WasmChannelError::Compilation(e.to_string()))?;
|
|
|
|
// Create linker and add host functions
|
|
let mut linker = Linker::new(engine);
|
|
Self::add_host_functions(&mut linker)?;
|
|
|
|
// Instantiate using the generated bindings
|
|
let instance = SandboxedChannel::instantiate(store, &component, &linker)
|
|
.map_err(|e| WasmChannelError::Instantiation(e.to_string()))?;
|
|
|
|
Ok(instance)
|
|
}
|
|
|
|
/// Map WASM execution errors to our error types.
|
|
fn map_wasm_error(e: anyhow::Error, name: &str, fuel_limit: u64) -> WasmChannelError {
|
|
let error_str = e.to_string();
|
|
if error_str.contains("out of fuel") {
|
|
WasmChannelError::FuelExhausted {
|
|
name: name.to_string(),
|
|
limit: fuel_limit,
|
|
}
|
|
} else if error_str.contains("unreachable") {
|
|
WasmChannelError::Trapped {
|
|
name: name.to_string(),
|
|
reason: "unreachable code executed".to_string(),
|
|
}
|
|
} else {
|
|
WasmChannelError::Trapped {
|
|
name: name.to_string(),
|
|
reason: error_str,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extract host state after callback execution.
|
|
fn extract_host_state(
|
|
store: &mut Store<ChannelStoreData>,
|
|
channel_name: &str,
|
|
capabilities: &ChannelCapabilities,
|
|
) -> ChannelHostState {
|
|
std::mem::replace(
|
|
&mut store.data_mut().host_state,
|
|
ChannelHostState::new(channel_name, capabilities.clone()),
|
|
)
|
|
}
|
|
|
|
/// Execute the on_start callback.
|
|
///
|
|
/// Returns the channel configuration for HTTP endpoint registration.
|
|
async fn call_on_start(&self) -> Result<ChannelConfig, WasmChannelError> {
|
|
// If no WASM bytes, return default config (for testing)
|
|
if self.prepared.component_bytes.is_empty() {
|
|
tracing::info!(
|
|
channel = %self.name,
|
|
"WASM channel on_start called (no WASM module, returning defaults)"
|
|
);
|
|
return Ok(ChannelConfig {
|
|
display_name: self.prepared.description.clone(),
|
|
http_endpoints: Vec::new(),
|
|
poll: None,
|
|
});
|
|
}
|
|
|
|
let runtime = Arc::clone(&self.runtime);
|
|
let prepared = Arc::clone(&self.prepared);
|
|
let capabilities = self.capabilities.clone();
|
|
let config_json = self.config_json.read().await.clone();
|
|
let timeout = self.runtime.config().callback_timeout;
|
|
let channel_name = self.name.clone();
|
|
let credentials = self.get_credentials().await;
|
|
|
|
// Execute in blocking task with timeout
|
|
let result = tokio::time::timeout(timeout, async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut store =
|
|
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
|
|
|
// Call on_start using the generated typed interface
|
|
let channel_iface = instance.near_agent_channel();
|
|
let wasm_result = channel_iface
|
|
.call_on_start(&mut store, &config_json)
|
|
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
|
|
|
|
// Convert the result
|
|
let config = match wasm_result {
|
|
Ok(wit_config) => convert_channel_config(wit_config),
|
|
Err(err_msg) => {
|
|
return Err(WasmChannelError::CallbackFailed {
|
|
name: prepared.name.clone(),
|
|
reason: err_msg,
|
|
});
|
|
}
|
|
};
|
|
|
|
let host_state =
|
|
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
|
|
Ok((config, host_state))
|
|
})
|
|
.await
|
|
.map_err(|e| WasmChannelError::ExecutionPanicked {
|
|
name: channel_name.clone(),
|
|
reason: e.to_string(),
|
|
})?
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok((config, _host_state))) => {
|
|
tracing::info!(
|
|
channel = %self.name,
|
|
display_name = %config.display_name,
|
|
endpoints = config.http_endpoints.len(),
|
|
"WASM channel on_start completed"
|
|
);
|
|
Ok(config)
|
|
}
|
|
Ok(Err(e)) => Err(e),
|
|
Err(_) => Err(WasmChannelError::Timeout {
|
|
name: self.name.clone(),
|
|
callback: "on_start".to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Execute the on_http_request callback.
|
|
///
|
|
/// Called when an HTTP request arrives at a registered endpoint.
|
|
pub async fn call_on_http_request(
|
|
&self,
|
|
method: &str,
|
|
path: &str,
|
|
headers: &HashMap<String, String>,
|
|
query: &HashMap<String, String>,
|
|
body: &[u8],
|
|
secret_validated: bool,
|
|
) -> Result<HttpResponse, WasmChannelError> {
|
|
tracing::info!(
|
|
channel = %self.name,
|
|
method = method,
|
|
path = path,
|
|
body_len = body.len(),
|
|
secret_validated = secret_validated,
|
|
"call_on_http_request invoked (webhook received)"
|
|
);
|
|
|
|
// Log the body for debugging (truncated at char boundary)
|
|
if let Ok(body_str) = std::str::from_utf8(body) {
|
|
let truncated = if body_str.chars().count() > 1000 {
|
|
format!("{}...", body_str.chars().take(1000).collect::<String>())
|
|
} else {
|
|
body_str.to_string()
|
|
};
|
|
tracing::debug!(body = %truncated, "Webhook request body");
|
|
}
|
|
|
|
// Log credentials state (without values)
|
|
let creds = self.get_credentials().await;
|
|
tracing::info!(
|
|
credential_count = creds.len(),
|
|
credential_names = ?creds.keys().collect::<Vec<_>>(),
|
|
"Credentials available for on_http_request"
|
|
);
|
|
|
|
// If no WASM bytes, return 200 OK (for testing)
|
|
if self.prepared.component_bytes.is_empty() {
|
|
tracing::debug!(
|
|
channel = %self.name,
|
|
method = method,
|
|
path = path,
|
|
"WASM channel on_http_request called (no WASM module)"
|
|
);
|
|
return Ok(HttpResponse::ok());
|
|
}
|
|
|
|
let runtime = Arc::clone(&self.runtime);
|
|
let prepared = Arc::clone(&self.prepared);
|
|
let capabilities = self.capabilities.clone();
|
|
let timeout = self.runtime.config().callback_timeout;
|
|
let credentials = self.get_credentials().await;
|
|
|
|
// Prepare request data
|
|
let method = method.to_string();
|
|
let path = path.to_string();
|
|
let headers_json = serde_json::to_string(&headers).unwrap_or_default();
|
|
let query_json = serde_json::to_string(&query).unwrap_or_default();
|
|
let body = body.to_vec();
|
|
|
|
let channel_name = self.name.clone();
|
|
|
|
// Execute in blocking task with timeout
|
|
let result = tokio::time::timeout(timeout, async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut store =
|
|
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
|
|
|
// Build the WIT request type
|
|
let wit_request = wit_channel::IncomingHttpRequest {
|
|
method,
|
|
path,
|
|
headers_json,
|
|
query_json,
|
|
body,
|
|
secret_validated,
|
|
};
|
|
|
|
// Call on_http_request using the generated typed interface
|
|
let channel_iface = instance.near_agent_channel();
|
|
let wit_response = channel_iface
|
|
.call_on_http_request(&mut store, &wit_request)
|
|
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
|
|
|
|
let response = convert_http_response(wit_response);
|
|
let host_state =
|
|
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
|
|
Ok((response, host_state))
|
|
})
|
|
.await
|
|
.map_err(|e| WasmChannelError::ExecutionPanicked {
|
|
name: channel_name.clone(),
|
|
reason: e.to_string(),
|
|
})?
|
|
})
|
|
.await;
|
|
|
|
let channel_name = self.name.clone();
|
|
match result {
|
|
Ok(Ok((response, mut host_state))) => {
|
|
// Process emitted messages
|
|
let emitted = host_state.take_emitted_messages();
|
|
self.process_emitted_messages(emitted).await?;
|
|
|
|
tracing::debug!(
|
|
channel = %channel_name,
|
|
status = response.status,
|
|
"WASM channel on_http_request completed"
|
|
);
|
|
Ok(response)
|
|
}
|
|
Ok(Err(e)) => Err(e),
|
|
Err(_) => Err(WasmChannelError::Timeout {
|
|
name: channel_name,
|
|
callback: "on_http_request".to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Execute the on_poll callback.
|
|
///
|
|
/// Called periodically if polling is configured.
|
|
pub async fn call_on_poll(&self) -> Result<(), WasmChannelError> {
|
|
// If no WASM bytes, do nothing (for testing)
|
|
if self.prepared.component_bytes.is_empty() {
|
|
tracing::debug!(
|
|
channel = %self.name,
|
|
"WASM channel on_poll called (no WASM module)"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let runtime = Arc::clone(&self.runtime);
|
|
let prepared = Arc::clone(&self.prepared);
|
|
let capabilities = self.capabilities.clone();
|
|
let timeout = self.runtime.config().callback_timeout;
|
|
let channel_name = self.name.clone();
|
|
let credentials = self.get_credentials().await;
|
|
|
|
// Execute in blocking task with timeout
|
|
let result = tokio::time::timeout(timeout, async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut store =
|
|
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
|
|
|
// Call on_poll using the generated typed interface
|
|
let channel_iface = instance.near_agent_channel();
|
|
channel_iface
|
|
.call_on_poll(&mut store)
|
|
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
|
|
|
|
let host_state =
|
|
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
|
|
Ok(((), host_state))
|
|
})
|
|
.await
|
|
.map_err(|e| WasmChannelError::ExecutionPanicked {
|
|
name: channel_name.clone(),
|
|
reason: e.to_string(),
|
|
})?
|
|
})
|
|
.await;
|
|
|
|
let channel_name = self.name.clone();
|
|
match result {
|
|
Ok(Ok(((), mut host_state))) => {
|
|
// Process emitted messages
|
|
let emitted = host_state.take_emitted_messages();
|
|
self.process_emitted_messages(emitted).await?;
|
|
|
|
tracing::debug!(
|
|
channel = %channel_name,
|
|
"WASM channel on_poll completed"
|
|
);
|
|
Ok(())
|
|
}
|
|
Ok(Err(e)) => Err(e),
|
|
Err(_) => Err(WasmChannelError::Timeout {
|
|
name: channel_name,
|
|
callback: "on_poll".to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Execute the on_respond callback.
|
|
///
|
|
/// Called when the agent has a response to send back.
|
|
pub async fn call_on_respond(
|
|
&self,
|
|
message_id: Uuid,
|
|
content: &str,
|
|
thread_id: Option<&str>,
|
|
metadata_json: &str,
|
|
) -> Result<(), WasmChannelError> {
|
|
tracing::info!(
|
|
channel = %self.name,
|
|
message_id = %message_id,
|
|
content_len = content.len(),
|
|
thread_id = ?thread_id,
|
|
"call_on_respond invoked"
|
|
);
|
|
|
|
// Log credentials state (without values)
|
|
let creds = self.get_credentials().await;
|
|
tracing::info!(
|
|
credential_count = creds.len(),
|
|
credential_names = ?creds.keys().collect::<Vec<_>>(),
|
|
"Credentials available for on_respond"
|
|
);
|
|
|
|
// If no WASM bytes, do nothing (for testing)
|
|
if self.prepared.component_bytes.is_empty() {
|
|
tracing::debug!(
|
|
channel = %self.name,
|
|
message_id = %message_id,
|
|
"WASM channel on_respond called (no WASM module)"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let runtime = Arc::clone(&self.runtime);
|
|
let prepared = Arc::clone(&self.prepared);
|
|
let capabilities = self.capabilities.clone();
|
|
let timeout = self.runtime.config().callback_timeout;
|
|
let channel_name = self.name.clone();
|
|
let credentials = self.get_credentials().await;
|
|
|
|
// Prepare response data
|
|
let message_id_str = message_id.to_string();
|
|
let content = content.to_string();
|
|
let thread_id = thread_id.map(|s| s.to_string());
|
|
let metadata_json = metadata_json.to_string();
|
|
|
|
// Execute in blocking task with timeout
|
|
tracing::info!(channel = %channel_name, "Starting on_respond WASM execution");
|
|
|
|
let result = tokio::time::timeout(timeout, async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
tracing::info!("Creating WASM store for on_respond");
|
|
let mut store =
|
|
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
|
|
|
tracing::info!("Instantiating WASM component for on_respond");
|
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
|
|
|
// Build the WIT response type
|
|
let wit_response = wit_channel::AgentResponse {
|
|
message_id: message_id_str,
|
|
content: content.clone(),
|
|
thread_id,
|
|
metadata_json,
|
|
};
|
|
|
|
// Truncate at char boundary for logging (avoid panic on multi-byte UTF-8)
|
|
let content_preview: String = content.chars().take(50).collect();
|
|
tracing::info!(
|
|
content_preview = %content_preview,
|
|
"Calling WASM on_respond"
|
|
);
|
|
|
|
// Call on_respond using the generated typed interface
|
|
let channel_iface = instance.near_agent_channel();
|
|
let wasm_result = channel_iface
|
|
.call_on_respond(&mut store, &wit_response)
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "WASM on_respond call failed");
|
|
Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel)
|
|
})?;
|
|
|
|
tracing::info!(wasm_result = ?wasm_result, "WASM on_respond returned");
|
|
|
|
// Check for WASM-level errors
|
|
if let Err(ref err_msg) = wasm_result {
|
|
tracing::error!(error = %err_msg, "WASM on_respond returned error");
|
|
return Err(WasmChannelError::CallbackFailed {
|
|
name: prepared.name.clone(),
|
|
reason: err_msg.clone(),
|
|
});
|
|
}
|
|
|
|
let host_state =
|
|
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
|
|
tracing::info!("on_respond WASM execution completed successfully");
|
|
Ok(((), host_state))
|
|
})
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "spawn_blocking panicked");
|
|
WasmChannelError::ExecutionPanicked {
|
|
name: channel_name.clone(),
|
|
reason: e.to_string(),
|
|
}
|
|
})?
|
|
})
|
|
.await;
|
|
|
|
let channel_name = self.name.clone();
|
|
match result {
|
|
Ok(Ok(((), _host_state))) => {
|
|
tracing::debug!(
|
|
channel = %channel_name,
|
|
message_id = %message_id,
|
|
"WASM channel on_respond completed"
|
|
);
|
|
Ok(())
|
|
}
|
|
Ok(Err(e)) => Err(e),
|
|
Err(_) => Err(WasmChannelError::Timeout {
|
|
name: channel_name,
|
|
callback: "on_respond".to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Execute the on_status callback.
|
|
///
|
|
/// Called to notify the WASM channel of agent status changes (e.g., typing).
|
|
pub async fn call_on_status(
|
|
&self,
|
|
status: &StatusUpdate,
|
|
metadata: &serde_json::Value,
|
|
) -> Result<(), WasmChannelError> {
|
|
// If no WASM bytes, do nothing (for testing)
|
|
if self.prepared.component_bytes.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let runtime = Arc::clone(&self.runtime);
|
|
let prepared = Arc::clone(&self.prepared);
|
|
let capabilities = self.capabilities.clone();
|
|
let timeout = self.runtime.config().callback_timeout;
|
|
let channel_name = self.name.clone();
|
|
let credentials = self.get_credentials().await;
|
|
|
|
let wit_update = status_to_wit(status, metadata);
|
|
|
|
let result = tokio::time::timeout(timeout, async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut store =
|
|
Self::create_store(&runtime, &prepared, &capabilities, credentials)?;
|
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
|
|
|
let channel_iface = instance.near_agent_channel();
|
|
channel_iface
|
|
.call_on_status(&mut store, &wit_update)
|
|
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
|
|
|
|
Ok(())
|
|
})
|
|
.await
|
|
.map_err(|e| WasmChannelError::ExecutionPanicked {
|
|
name: channel_name.clone(),
|
|
reason: e.to_string(),
|
|
})?
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok(())) => {
|
|
tracing::debug!(
|
|
channel = %self.name,
|
|
"WASM channel on_status completed"
|
|
);
|
|
Ok(())
|
|
}
|
|
Ok(Err(e)) => Err(e),
|
|
Err(_) => Err(WasmChannelError::Timeout {
|
|
name: self.name.clone(),
|
|
callback: "on_status".to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Execute a single on_status callback with a fresh WASM instance.
|
|
///
|
|
/// Static method for use by the background typing repeat task (which
|
|
/// doesn't have access to `&self`).
|
|
async fn execute_status(
|
|
channel_name: &str,
|
|
runtime: &Arc<WasmChannelRuntime>,
|
|
prepared: &Arc<PreparedChannelModule>,
|
|
capabilities: &ChannelCapabilities,
|
|
credentials: &RwLock<HashMap<String, String>>,
|
|
timeout: Duration,
|
|
wit_update: wit_channel::StatusUpdate,
|
|
) -> Result<(), WasmChannelError> {
|
|
if prepared.component_bytes.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let runtime = Arc::clone(runtime);
|
|
let prepared = Arc::clone(prepared);
|
|
let capabilities = capabilities.clone();
|
|
let credentials_snapshot = credentials.read().await.clone();
|
|
let channel_name_owned = channel_name.to_string();
|
|
|
|
let result = tokio::time::timeout(timeout, async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut store =
|
|
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
|
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
|
|
|
let channel_iface = instance.near_agent_channel();
|
|
channel_iface
|
|
.call_on_status(&mut store, &wit_update)
|
|
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
|
|
|
|
Ok(())
|
|
})
|
|
.await
|
|
.map_err(|e| WasmChannelError::ExecutionPanicked {
|
|
name: channel_name_owned.clone(),
|
|
reason: e.to_string(),
|
|
})?
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok(())) => Ok(()),
|
|
Ok(Err(e)) => Err(e),
|
|
Err(_) => Err(WasmChannelError::Timeout {
|
|
name: channel_name.to_string(),
|
|
callback: "on_status".to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Cancel the background typing indicator task if running.
|
|
async fn cancel_typing_task(&self) {
|
|
if let Some(handle) = self.typing_task.write().await.take() {
|
|
handle.abort();
|
|
}
|
|
}
|
|
|
|
/// Handle a status update, managing the typing repeat timer.
|
|
///
|
|
/// On Thinking: fires on_status once, then spawns a background task
|
|
/// that repeats the call every 4 seconds (Telegram's typing indicator
|
|
/// expires after ~5s).
|
|
///
|
|
/// On Done/Interrupted/Status: cancels the repeat task, fires on_status once.
|
|
/// On StreamChunk: no-op (too noisy).
|
|
async fn handle_status_update(
|
|
&self,
|
|
status: StatusUpdate,
|
|
metadata: &serde_json::Value,
|
|
) -> Result<(), ChannelError> {
|
|
match &status {
|
|
StatusUpdate::Thinking(_) => {
|
|
// Cancel any existing typing task
|
|
self.cancel_typing_task().await;
|
|
|
|
// Fire once immediately
|
|
if let Err(e) = self.call_on_status(&status, metadata).await {
|
|
tracing::debug!(
|
|
channel = %self.name,
|
|
error = %e,
|
|
"on_status(Thinking) failed (best-effort)"
|
|
);
|
|
}
|
|
|
|
// Spawn background repeater
|
|
let channel_name = self.name.clone();
|
|
let runtime = Arc::clone(&self.runtime);
|
|
let prepared = Arc::clone(&self.prepared);
|
|
let capabilities = self.capabilities.clone();
|
|
let credentials = self.credentials.clone();
|
|
let callback_timeout = self.runtime.config().callback_timeout;
|
|
let wit_update = status_to_wit(&status, metadata);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(Duration::from_secs(4));
|
|
// Skip the first tick (we already fired above)
|
|
interval.tick().await;
|
|
|
|
loop {
|
|
interval.tick().await;
|
|
|
|
let wit_update_clone = clone_wit_status_update(&wit_update);
|
|
|
|
if let Err(e) = Self::execute_status(
|
|
&channel_name,
|
|
&runtime,
|
|
&prepared,
|
|
&capabilities,
|
|
&credentials,
|
|
callback_timeout,
|
|
wit_update_clone,
|
|
)
|
|
.await
|
|
{
|
|
tracing::debug!(
|
|
channel = %channel_name,
|
|
error = %e,
|
|
"Typing repeat on_status failed (best-effort)"
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
*self.typing_task.write().await = Some(handle);
|
|
}
|
|
StatusUpdate::StreamChunk(_) => {
|
|
// No-op, too noisy
|
|
}
|
|
_ => {
|
|
// Done, Interrupted, Status, ToolStarted, ToolCompleted: cancel and fire once
|
|
self.cancel_typing_task().await;
|
|
|
|
if let Err(e) = self.call_on_status(&status, metadata).await {
|
|
tracing::debug!(
|
|
channel = %self.name,
|
|
error = %e,
|
|
"on_status failed (best-effort)"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Process emitted messages from a callback.
|
|
async fn process_emitted_messages(
|
|
&self,
|
|
messages: Vec<EmittedMessage>,
|
|
) -> Result<(), WasmChannelError> {
|
|
tracing::info!(
|
|
channel = %self.name,
|
|
message_count = messages.len(),
|
|
"Processing emitted messages from WASM callback"
|
|
);
|
|
|
|
if messages.is_empty() {
|
|
tracing::debug!(channel = %self.name, "No messages emitted");
|
|
return Ok(());
|
|
}
|
|
|
|
let tx_guard = self.message_tx.read().await;
|
|
let Some(tx) = tx_guard.as_ref() else {
|
|
tracing::error!(
|
|
channel = %self.name,
|
|
count = messages.len(),
|
|
"Messages emitted but no sender available - channel may not be started!"
|
|
);
|
|
return Ok(());
|
|
};
|
|
|
|
let mut rate_limiter = self.rate_limiter.write().await;
|
|
|
|
for emitted in messages {
|
|
// Check rate limit
|
|
if !rate_limiter.check_and_record() {
|
|
tracing::warn!(
|
|
channel = %self.name,
|
|
"Message emission rate limited"
|
|
);
|
|
return Err(WasmChannelError::EmitRateLimited {
|
|
name: self.name.clone(),
|
|
});
|
|
}
|
|
|
|
// Convert to IncomingMessage
|
|
let mut msg = IncomingMessage::new(&self.name, &emitted.user_id, &emitted.content);
|
|
|
|
if let Some(name) = emitted.user_name {
|
|
msg = msg.with_user_name(name);
|
|
}
|
|
|
|
if let Some(thread_id) = emitted.thread_id {
|
|
msg = msg.with_thread(thread_id);
|
|
}
|
|
|
|
// Parse metadata JSON
|
|
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
|
msg = msg.with_metadata(metadata);
|
|
}
|
|
|
|
// Send to stream
|
|
tracing::info!(
|
|
channel = %self.name,
|
|
user_id = %emitted.user_id,
|
|
content_len = emitted.content.len(),
|
|
"Sending emitted message to agent"
|
|
);
|
|
|
|
if tx.send(msg).await.is_err() {
|
|
tracing::error!(
|
|
channel = %self.name,
|
|
"Failed to send emitted message, channel closed"
|
|
);
|
|
break;
|
|
}
|
|
|
|
tracing::info!(
|
|
channel = %self.name,
|
|
"Message successfully sent to agent queue"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Start the polling loop if configured.
|
|
///
|
|
/// Since we can't hold `Arc<Self>` from `&self`, we pass all the components
|
|
/// needed for polling to a spawned task. Each poll tick creates a fresh WASM
|
|
/// instance (matching our "fresh instance per callback" pattern).
|
|
fn start_polling(&self, interval: Duration, shutdown_rx: oneshot::Receiver<()>) {
|
|
let channel_name = self.name.clone();
|
|
let runtime = Arc::clone(&self.runtime);
|
|
let prepared = Arc::clone(&self.prepared);
|
|
let capabilities = self.capabilities.clone();
|
|
let message_tx = self.message_tx.clone();
|
|
let rate_limiter = self.rate_limiter.clone();
|
|
let credentials = self.credentials.clone();
|
|
let callback_timeout = self.runtime.config().callback_timeout;
|
|
|
|
tokio::spawn(async move {
|
|
let mut interval_timer = tokio::time::interval(interval);
|
|
let mut shutdown = std::pin::pin!(shutdown_rx);
|
|
|
|
loop {
|
|
tokio::select! {
|
|
_ = interval_timer.tick() => {
|
|
tracing::debug!(
|
|
channel = %channel_name,
|
|
"Polling tick - calling on_poll"
|
|
);
|
|
|
|
// Execute on_poll with fresh WASM instance
|
|
let result = Self::execute_poll(
|
|
&channel_name,
|
|
&runtime,
|
|
&prepared,
|
|
&capabilities,
|
|
&credentials,
|
|
callback_timeout,
|
|
).await;
|
|
|
|
match result {
|
|
Ok(emitted_messages) => {
|
|
// Process any emitted messages
|
|
if !emitted_messages.is_empty() {
|
|
if let Err(e) = Self::dispatch_emitted_messages(
|
|
&channel_name,
|
|
emitted_messages,
|
|
&message_tx,
|
|
&rate_limiter,
|
|
).await {
|
|
tracing::warn!(
|
|
channel = %channel_name,
|
|
error = %e,
|
|
"Failed to dispatch emitted messages from poll"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
channel = %channel_name,
|
|
error = %e,
|
|
"Polling callback failed"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
_ = &mut shutdown => {
|
|
tracing::info!(
|
|
channel = %channel_name,
|
|
"Polling stopped"
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Execute a single poll callback with a fresh WASM instance.
|
|
///
|
|
/// Returns any emitted messages from the callback.
|
|
async fn execute_poll(
|
|
channel_name: &str,
|
|
runtime: &Arc<WasmChannelRuntime>,
|
|
prepared: &Arc<PreparedChannelModule>,
|
|
capabilities: &ChannelCapabilities,
|
|
credentials: &RwLock<HashMap<String, String>>,
|
|
timeout: Duration,
|
|
) -> Result<Vec<EmittedMessage>, WasmChannelError> {
|
|
// Skip if no WASM bytes (testing mode)
|
|
if prepared.component_bytes.is_empty() {
|
|
tracing::debug!(
|
|
channel = %channel_name,
|
|
"WASM channel on_poll called (no WASM module)"
|
|
);
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let runtime = Arc::clone(runtime);
|
|
let prepared = Arc::clone(prepared);
|
|
let capabilities = capabilities.clone();
|
|
let credentials_snapshot = credentials.read().await.clone();
|
|
let channel_name_owned = channel_name.to_string();
|
|
|
|
// Execute in blocking task with timeout
|
|
let result = tokio::time::timeout(timeout, async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut store =
|
|
Self::create_store(&runtime, &prepared, &capabilities, credentials_snapshot)?;
|
|
let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?;
|
|
|
|
// Call on_poll using the generated typed interface
|
|
let channel_iface = instance.near_agent_channel();
|
|
channel_iface
|
|
.call_on_poll(&mut store)
|
|
.map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?;
|
|
|
|
let host_state =
|
|
Self::extract_host_state(&mut store, &prepared.name, &capabilities);
|
|
Ok(host_state)
|
|
})
|
|
.await
|
|
.map_err(|e| WasmChannelError::ExecutionPanicked {
|
|
name: channel_name_owned.clone(),
|
|
reason: e.to_string(),
|
|
})?
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok(mut host_state)) => {
|
|
let emitted = host_state.take_emitted_messages();
|
|
tracing::debug!(
|
|
channel = %channel_name,
|
|
emitted_count = emitted.len(),
|
|
"WASM channel on_poll completed"
|
|
);
|
|
Ok(emitted)
|
|
}
|
|
Ok(Err(e)) => Err(e),
|
|
Err(_) => Err(WasmChannelError::Timeout {
|
|
name: channel_name.to_string(),
|
|
callback: "on_poll".to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Dispatch emitted messages to the message channel.
|
|
///
|
|
/// This is a static helper used by the polling loop since it doesn't have
|
|
/// access to `&self`.
|
|
async fn dispatch_emitted_messages(
|
|
channel_name: &str,
|
|
messages: Vec<EmittedMessage>,
|
|
message_tx: &RwLock<Option<mpsc::Sender<IncomingMessage>>>,
|
|
rate_limiter: &RwLock<ChannelEmitRateLimiter>,
|
|
) -> Result<(), WasmChannelError> {
|
|
tracing::info!(
|
|
channel = %channel_name,
|
|
message_count = messages.len(),
|
|
"Processing emitted messages from polling callback"
|
|
);
|
|
|
|
let tx_guard = message_tx.read().await;
|
|
let Some(tx) = tx_guard.as_ref() else {
|
|
tracing::error!(
|
|
channel = %channel_name,
|
|
count = messages.len(),
|
|
"Messages emitted but no sender available - channel may not be started!"
|
|
);
|
|
return Ok(());
|
|
};
|
|
|
|
let mut limiter = rate_limiter.write().await;
|
|
|
|
for emitted in messages {
|
|
// Check rate limit
|
|
if !limiter.check_and_record() {
|
|
tracing::warn!(
|
|
channel = %channel_name,
|
|
"Message emission rate limited"
|
|
);
|
|
return Err(WasmChannelError::EmitRateLimited {
|
|
name: channel_name.to_string(),
|
|
});
|
|
}
|
|
|
|
// Convert to IncomingMessage
|
|
let mut msg = IncomingMessage::new(channel_name, &emitted.user_id, &emitted.content);
|
|
|
|
if let Some(name) = emitted.user_name {
|
|
msg = msg.with_user_name(name);
|
|
}
|
|
|
|
if let Some(thread_id) = emitted.thread_id {
|
|
msg = msg.with_thread(thread_id);
|
|
}
|
|
|
|
// Parse metadata JSON
|
|
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
|
|
msg = msg.with_metadata(metadata);
|
|
}
|
|
|
|
// Send to stream
|
|
tracing::info!(
|
|
channel = %channel_name,
|
|
user_id = %emitted.user_id,
|
|
content_len = emitted.content.len(),
|
|
"Sending polled message to agent"
|
|
);
|
|
|
|
if tx.send(msg).await.is_err() {
|
|
tracing::error!(
|
|
channel = %channel_name,
|
|
"Failed to send polled message, channel closed"
|
|
);
|
|
break;
|
|
}
|
|
|
|
tracing::info!(
|
|
channel = %channel_name,
|
|
"Message successfully sent to agent queue"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for WasmChannel {
|
|
fn name(&self) -> &str {
|
|
&self.name
|
|
}
|
|
|
|
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
|
// Create message channel
|
|
let (tx, rx) = mpsc::channel(256);
|
|
*self.message_tx.write().await = Some(tx);
|
|
|
|
// Create shutdown channel
|
|
let (shutdown_tx, _shutdown_rx) = oneshot::channel();
|
|
*self.shutdown_tx.write().await = Some(shutdown_tx);
|
|
|
|
// Call on_start to get configuration
|
|
let config = self
|
|
.call_on_start()
|
|
.await
|
|
.map_err(|e| ChannelError::StartupFailed {
|
|
name: self.name.clone(),
|
|
reason: e.to_string(),
|
|
})?;
|
|
|
|
// Store the config
|
|
*self.channel_config.write().await = Some(config.clone());
|
|
|
|
// Register HTTP endpoints
|
|
let mut endpoints = Vec::new();
|
|
for endpoint in &config.http_endpoints {
|
|
// Validate path is allowed
|
|
if !self.capabilities.is_path_allowed(&endpoint.path) {
|
|
tracing::warn!(
|
|
channel = %self.name,
|
|
path = %endpoint.path,
|
|
"HTTP endpoint path not allowed by capabilities"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
endpoints.push(RegisteredEndpoint {
|
|
channel_name: self.name.clone(),
|
|
path: endpoint.path.clone(),
|
|
methods: endpoint.methods.clone(),
|
|
require_secret: endpoint.require_secret,
|
|
});
|
|
}
|
|
*self.endpoints.write().await = endpoints;
|
|
|
|
// Start polling if configured
|
|
if let Some(poll_config) = &config.poll {
|
|
if poll_config.enabled {
|
|
let interval = self
|
|
.capabilities
|
|
.validate_poll_interval(poll_config.interval_ms)
|
|
.map_err(|e| ChannelError::StartupFailed {
|
|
name: self.name.clone(),
|
|
reason: e,
|
|
})?;
|
|
|
|
// Create shutdown channel for polling and store the sender to keep it alive
|
|
let (poll_shutdown_tx, poll_shutdown_rx) = oneshot::channel();
|
|
*self.poll_shutdown_tx.write().await = Some(poll_shutdown_tx);
|
|
|
|
self.start_polling(Duration::from_millis(interval as u64), poll_shutdown_rx);
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
channel = %self.name,
|
|
display_name = %config.display_name,
|
|
endpoints = config.http_endpoints.len(),
|
|
"WASM channel started"
|
|
);
|
|
|
|
Ok(Box::pin(ReceiverStream::new(rx)))
|
|
}
|
|
|
|
async fn respond(
|
|
&self,
|
|
msg: &IncomingMessage,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
// Stop the typing indicator, we're about to send the actual response
|
|
self.cancel_typing_task().await;
|
|
|
|
// Check if there's a pending synchronous response waiter
|
|
if let Some(tx) = self.pending_responses.write().await.remove(&msg.id) {
|
|
let _ = tx.send(response.content.clone());
|
|
}
|
|
|
|
// Call WASM on_respond
|
|
// IMPORTANT: Use the ORIGINAL message's metadata, not the response's metadata.
|
|
// The original metadata contains channel-specific routing info (e.g., Telegram chat_id)
|
|
// that the WASM channel needs to send the reply to the correct destination.
|
|
let metadata_json = serde_json::to_string(&msg.metadata).unwrap_or_default();
|
|
self.call_on_respond(
|
|
msg.id,
|
|
&response.content,
|
|
response.thread_id.as_deref(),
|
|
&metadata_json,
|
|
)
|
|
.await
|
|
.map_err(|e| ChannelError::SendFailed {
|
|
name: self.name.clone(),
|
|
reason: e.to_string(),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_status(
|
|
&self,
|
|
status: StatusUpdate,
|
|
metadata: &serde_json::Value,
|
|
) -> Result<(), ChannelError> {
|
|
// Delegate to the typing indicator implementation
|
|
self.handle_status_update(status, metadata).await
|
|
}
|
|
|
|
async fn health_check(&self) -> Result<(), ChannelError> {
|
|
// Check if we have an active message sender
|
|
if self.message_tx.read().await.is_some() {
|
|
Ok(())
|
|
} else {
|
|
Err(ChannelError::HealthCheckFailed {
|
|
name: self.name.clone(),
|
|
})
|
|
}
|
|
}
|
|
|
|
async fn shutdown(&self) -> Result<(), ChannelError> {
|
|
// Cancel typing indicator
|
|
self.cancel_typing_task().await;
|
|
|
|
// Send shutdown signal
|
|
if let Some(tx) = self.shutdown_tx.write().await.take() {
|
|
let _ = tx.send(());
|
|
}
|
|
|
|
// Stop polling by dropping the sender (receiver will complete)
|
|
let _ = self.poll_shutdown_tx.write().await.take();
|
|
|
|
// Clear the message sender
|
|
*self.message_tx.write().await = None;
|
|
|
|
tracing::info!(
|
|
channel = %self.name,
|
|
"WASM channel shut down"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for WasmChannel {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("WasmChannel")
|
|
.field("name", &self.name)
|
|
.field("prepared", &self.prepared.name)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Shared Channel Wrapper
|
|
// ============================================================================
|
|
|
|
/// A wrapper around `Arc<WasmChannel>` that implements `Channel`.
|
|
///
|
|
/// This allows sharing the same WasmChannel instance between:
|
|
/// - The WasmChannelRouter (for webhook handling)
|
|
/// - The ChannelManager (for message streaming and responses)
|
|
pub struct SharedWasmChannel {
|
|
inner: Arc<WasmChannel>,
|
|
}
|
|
|
|
impl SharedWasmChannel {
|
|
/// Create a new shared wrapper.
|
|
pub fn new(channel: Arc<WasmChannel>) -> Self {
|
|
Self { inner: channel }
|
|
}
|
|
|
|
/// Get the inner Arc.
|
|
pub fn inner(&self) -> &Arc<WasmChannel> {
|
|
&self.inner
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for SharedWasmChannel {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("SharedWasmChannel")
|
|
.field("inner", &self.inner)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for SharedWasmChannel {
|
|
fn name(&self) -> &str {
|
|
self.inner.name()
|
|
}
|
|
|
|
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
|
self.inner.start().await
|
|
}
|
|
|
|
async fn respond(
|
|
&self,
|
|
msg: &IncomingMessage,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
self.inner.respond(msg, response).await
|
|
}
|
|
|
|
async fn send_status(
|
|
&self,
|
|
status: StatusUpdate,
|
|
metadata: &serde_json::Value,
|
|
) -> Result<(), ChannelError> {
|
|
self.inner.send_status(status, metadata).await
|
|
}
|
|
|
|
async fn health_check(&self) -> Result<(), ChannelError> {
|
|
self.inner.health_check().await
|
|
}
|
|
|
|
async fn shutdown(&self) -> Result<(), ChannelError> {
|
|
self.inner.shutdown().await
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// WIT Type Conversion Helpers
|
|
// ============================================================================
|
|
|
|
// Type aliases for the generated WIT types (exported interface)
|
|
use exports::near::agent::channel as wit_channel;
|
|
|
|
/// Convert WIT-generated ChannelConfig to our internal type.
|
|
fn convert_channel_config(wit: wit_channel::ChannelConfig) -> ChannelConfig {
|
|
ChannelConfig {
|
|
display_name: wit.display_name,
|
|
http_endpoints: wit
|
|
.http_endpoints
|
|
.into_iter()
|
|
.map(
|
|
|ep| crate::channels::wasm::schema::HttpEndpointConfigSchema {
|
|
path: ep.path,
|
|
methods: ep.methods,
|
|
require_secret: ep.require_secret,
|
|
},
|
|
)
|
|
.collect(),
|
|
poll: wit
|
|
.poll
|
|
.map(|p| crate::channels::wasm::schema::PollConfigSchema {
|
|
interval_ms: p.interval_ms,
|
|
enabled: p.enabled,
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Convert WIT-generated OutgoingHttpResponse to our HttpResponse type.
|
|
fn convert_http_response(wit: wit_channel::OutgoingHttpResponse) -> HttpResponse {
|
|
let headers = serde_json::from_str(&wit.headers_json).unwrap_or_default();
|
|
HttpResponse {
|
|
status: wit.status,
|
|
headers,
|
|
body: wit.body,
|
|
}
|
|
}
|
|
|
|
/// Convert a StatusUpdate + metadata into the WIT StatusUpdate type.
|
|
fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_channel::StatusUpdate {
|
|
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
|
|
|
|
match status {
|
|
StatusUpdate::Thinking(msg) => wit_channel::StatusUpdate {
|
|
status: wit_channel::StatusType::Thinking,
|
|
message: msg.clone(),
|
|
metadata_json,
|
|
},
|
|
StatusUpdate::ToolStarted { name } => wit_channel::StatusUpdate {
|
|
status: wit_channel::StatusType::ToolStarted,
|
|
message: name.clone(),
|
|
metadata_json,
|
|
},
|
|
StatusUpdate::ToolCompleted { name, success } => wit_channel::StatusUpdate {
|
|
status: wit_channel::StatusType::ToolCompleted,
|
|
message: format!("{}: {}", name, if *success { "ok" } else { "failed" }),
|
|
metadata_json,
|
|
},
|
|
StatusUpdate::ToolResult { name, preview } => wit_channel::StatusUpdate {
|
|
status: wit_channel::StatusType::ToolCompleted,
|
|
message: format!("{}: {}", name, preview),
|
|
metadata_json,
|
|
},
|
|
StatusUpdate::StreamChunk(chunk) => wit_channel::StatusUpdate {
|
|
status: wit_channel::StatusType::Thinking,
|
|
message: chunk.clone(),
|
|
metadata_json,
|
|
},
|
|
StatusUpdate::Status(msg) => {
|
|
// Map well-known status strings to WIT types
|
|
let status_type = match msg.as_str() {
|
|
"Done" => wit_channel::StatusType::Done,
|
|
"Interrupted" => wit_channel::StatusType::Interrupted,
|
|
_ => wit_channel::StatusType::Thinking,
|
|
};
|
|
wit_channel::StatusUpdate {
|
|
status: status_type,
|
|
message: msg.clone(),
|
|
metadata_json,
|
|
}
|
|
}
|
|
StatusUpdate::ApprovalNeeded {
|
|
tool_name,
|
|
description,
|
|
..
|
|
} => wit_channel::StatusUpdate {
|
|
status: wit_channel::StatusType::Thinking,
|
|
message: format!("Approval needed: {} - {}", tool_name, description),
|
|
metadata_json,
|
|
},
|
|
StatusUpdate::JobStarted { job_id, title, .. } => wit_channel::StatusUpdate {
|
|
status: wit_channel::StatusType::Thinking,
|
|
message: format!("Job started: {} ({})", title, job_id),
|
|
metadata_json,
|
|
},
|
|
StatusUpdate::AuthRequired { extension_name, .. } => wit_channel::StatusUpdate {
|
|
status: wit_channel::StatusType::Thinking,
|
|
message: format!("Auth required: {}", extension_name),
|
|
metadata_json,
|
|
},
|
|
StatusUpdate::AuthCompleted {
|
|
extension_name,
|
|
success,
|
|
..
|
|
} => wit_channel::StatusUpdate {
|
|
status: wit_channel::StatusType::Thinking,
|
|
message: format!(
|
|
"Auth {}: {}",
|
|
if *success { "completed" } else { "failed" },
|
|
extension_name
|
|
),
|
|
metadata_json,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Clone a WIT StatusUpdate (the generated type doesn't derive Clone).
|
|
fn clone_wit_status_update(update: &wit_channel::StatusUpdate) -> wit_channel::StatusUpdate {
|
|
wit_channel::StatusUpdate {
|
|
status: match update.status {
|
|
wit_channel::StatusType::Thinking => wit_channel::StatusType::Thinking,
|
|
wit_channel::StatusType::Done => wit_channel::StatusType::Done,
|
|
wit_channel::StatusType::Interrupted => wit_channel::StatusType::Interrupted,
|
|
wit_channel::StatusType::ToolStarted => wit_channel::StatusType::ToolStarted,
|
|
wit_channel::StatusType::ToolCompleted => wit_channel::StatusType::ToolCompleted,
|
|
},
|
|
message: update.message.clone(),
|
|
metadata_json: update.metadata_json.clone(),
|
|
}
|
|
}
|
|
|
|
/// HTTP response from a WASM channel callback.
|
|
#[derive(Debug, Clone)]
|
|
pub struct HttpResponse {
|
|
/// HTTP status code.
|
|
pub status: u16,
|
|
/// Response headers.
|
|
pub headers: HashMap<String, String>,
|
|
/// Response body.
|
|
pub body: Vec<u8>,
|
|
}
|
|
|
|
impl HttpResponse {
|
|
/// Create an OK response.
|
|
pub fn ok() -> Self {
|
|
Self {
|
|
status: 200,
|
|
headers: HashMap::new(),
|
|
body: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Create a JSON response.
|
|
pub fn json(value: serde_json::Value) -> Self {
|
|
let body = serde_json::to_vec(&value).unwrap_or_default();
|
|
let mut headers = HashMap::new();
|
|
headers.insert("Content-Type".to_string(), "application/json".to_string());
|
|
Self {
|
|
status: 200,
|
|
headers,
|
|
body,
|
|
}
|
|
}
|
|
|
|
/// Create an error response.
|
|
pub fn error(status: u16, message: &str) -> Self {
|
|
Self {
|
|
status,
|
|
headers: HashMap::new(),
|
|
body: message.as_bytes().to_vec(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use crate::channels::Channel;
|
|
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
|
use crate::channels::wasm::runtime::{
|
|
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
|
};
|
|
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
|
|
use crate::tools::wasm::ResourceLimits;
|
|
|
|
fn create_test_channel() -> WasmChannel {
|
|
let config = WasmChannelRuntimeConfig::for_testing();
|
|
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
|
|
|
let prepared = Arc::new(PreparedChannelModule {
|
|
name: "test".to_string(),
|
|
description: "Test channel".to_string(),
|
|
component_bytes: Vec::new(),
|
|
limits: ResourceLimits::default(),
|
|
});
|
|
|
|
let capabilities = ChannelCapabilities::for_channel("test").with_path("/webhook/test");
|
|
|
|
WasmChannel::new(runtime, prepared, capabilities, "{}".to_string())
|
|
}
|
|
|
|
#[test]
|
|
fn test_channel_name() {
|
|
let channel = create_test_channel();
|
|
assert_eq!(channel.name(), "test");
|
|
}
|
|
|
|
#[test]
|
|
fn test_http_response_ok() {
|
|
let response = HttpResponse::ok();
|
|
assert_eq!(response.status, 200);
|
|
assert!(response.body.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_http_response_json() {
|
|
let response = HttpResponse::json(serde_json::json!({"key": "value"}));
|
|
assert_eq!(response.status, 200);
|
|
assert_eq!(
|
|
response.headers.get("Content-Type"),
|
|
Some(&"application/json".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_http_response_error() {
|
|
let response = HttpResponse::error(400, "Bad request");
|
|
assert_eq!(response.status, 400);
|
|
assert_eq!(response.body, b"Bad request");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_channel_start_and_shutdown() {
|
|
let channel = create_test_channel();
|
|
|
|
// Start should succeed
|
|
let stream = channel.start().await;
|
|
assert!(stream.is_ok());
|
|
|
|
// Health check should pass
|
|
assert!(channel.health_check().await.is_ok());
|
|
|
|
// Shutdown should succeed
|
|
assert!(channel.shutdown().await.is_ok());
|
|
|
|
// Health check should fail after shutdown
|
|
assert!(channel.health_check().await.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_execute_poll_no_wasm_returns_empty() {
|
|
// When there's no WASM module (empty component_bytes), execute_poll
|
|
// should return an empty vector of messages
|
|
let config = WasmChannelRuntimeConfig::for_testing();
|
|
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
|
|
|
let prepared = Arc::new(PreparedChannelModule {
|
|
name: "poll-test".to_string(),
|
|
description: "Test channel".to_string(),
|
|
component_bytes: Vec::new(), // No WASM bytes
|
|
limits: ResourceLimits::default(),
|
|
});
|
|
|
|
let capabilities = ChannelCapabilities::for_channel("poll-test").with_polling(1000);
|
|
let credentials = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new()));
|
|
let timeout = std::time::Duration::from_secs(5);
|
|
|
|
let result = WasmChannel::execute_poll(
|
|
"poll-test",
|
|
&runtime,
|
|
&prepared,
|
|
&capabilities,
|
|
&credentials,
|
|
timeout,
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
assert!(result.unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_dispatch_emitted_messages_sends_to_channel() {
|
|
use crate::channels::wasm::host::EmittedMessage;
|
|
|
|
let (tx, mut rx) = tokio::sync::mpsc::channel(10);
|
|
let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx)));
|
|
|
|
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
|
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
|
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
|
),
|
|
));
|
|
|
|
let messages = vec![
|
|
EmittedMessage::new("user1", "Hello from polling!"),
|
|
EmittedMessage::new("user2", "Another message"),
|
|
];
|
|
|
|
let result = WasmChannel::dispatch_emitted_messages(
|
|
"test-channel",
|
|
messages,
|
|
&message_tx,
|
|
&rate_limiter,
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
|
|
// Verify messages were sent
|
|
let msg1 = rx.try_recv().expect("Should receive first message");
|
|
assert_eq!(msg1.user_id, "user1");
|
|
assert_eq!(msg1.content, "Hello from polling!");
|
|
|
|
let msg2 = rx.try_recv().expect("Should receive second message");
|
|
assert_eq!(msg2.user_id, "user2");
|
|
assert_eq!(msg2.content, "Another message");
|
|
|
|
// No more messages
|
|
assert!(rx.try_recv().is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_dispatch_emitted_messages_no_sender_returns_ok() {
|
|
use crate::channels::wasm::host::EmittedMessage;
|
|
|
|
// No sender available (channel not started)
|
|
let message_tx = Arc::new(tokio::sync::RwLock::new(None));
|
|
let rate_limiter = Arc::new(tokio::sync::RwLock::new(
|
|
crate::channels::wasm::host::ChannelEmitRateLimiter::new(
|
|
crate::channels::wasm::capabilities::EmitRateLimitConfig::default(),
|
|
),
|
|
));
|
|
|
|
let messages = vec![EmittedMessage::new("user1", "Hello!")];
|
|
|
|
// Should return Ok even without a sender (logs warning but doesn't fail)
|
|
let result = WasmChannel::dispatch_emitted_messages(
|
|
"test-channel",
|
|
messages,
|
|
&message_tx,
|
|
&rate_limiter,
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_channel_with_polling_stores_shutdown_sender() {
|
|
// Create a channel with polling capabilities
|
|
let config = WasmChannelRuntimeConfig::for_testing();
|
|
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
|
|
|
let prepared = Arc::new(PreparedChannelModule {
|
|
name: "poll-channel".to_string(),
|
|
description: "Polling test channel".to_string(),
|
|
component_bytes: Vec::new(),
|
|
limits: ResourceLimits::default(),
|
|
});
|
|
|
|
// Enable polling with a 1 second minimum interval
|
|
let capabilities = ChannelCapabilities::for_channel("poll-channel")
|
|
.with_path("/webhook/poll")
|
|
.with_polling(1000);
|
|
|
|
let channel = WasmChannel::new(runtime, prepared, capabilities, "{}".to_string());
|
|
|
|
// Start the channel
|
|
let _stream = channel.start().await.expect("Channel should start");
|
|
|
|
// Verify poll_shutdown_tx is set (polling was started)
|
|
// Note: For testing channels without WASM, on_start returns no poll config,
|
|
// so polling won't actually be started. This verifies the basic lifecycle.
|
|
assert!(channel.health_check().await.is_ok());
|
|
|
|
// Shutdown should clean up properly
|
|
channel.shutdown().await.expect("Shutdown should succeed");
|
|
assert!(channel.health_check().await.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_call_on_poll_no_wasm_succeeds() {
|
|
// Verify call_on_poll returns Ok when there's no WASM module
|
|
let channel = create_test_channel();
|
|
|
|
// Start the channel first to set up message_tx
|
|
let _stream = channel.start().await.expect("Channel should start");
|
|
|
|
// call_on_poll should succeed (no-op for no WASM)
|
|
let result = channel.call_on_poll().await;
|
|
assert!(result.is_ok());
|
|
|
|
channel.shutdown().await.expect("Shutdown should succeed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_typing_task_starts_on_thinking() {
|
|
let channel = create_test_channel();
|
|
let _stream = channel.start().await.expect("Channel should start");
|
|
|
|
let metadata = serde_json::json!({"chat_id": 123});
|
|
|
|
// Sending Thinking should succeed (no-op for no WASM)
|
|
let result = channel
|
|
.send_status(
|
|
crate::channels::StatusUpdate::Thinking("Processing...".into()),
|
|
&metadata,
|
|
)
|
|
.await;
|
|
assert!(result.is_ok());
|
|
|
|
// A typing task should have been spawned
|
|
assert!(channel.typing_task.read().await.is_some());
|
|
|
|
// Shutdown should cancel the typing task
|
|
channel.shutdown().await.expect("Shutdown should succeed");
|
|
assert!(channel.typing_task.read().await.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_typing_task_cancelled_on_done() {
|
|
let channel = create_test_channel();
|
|
let _stream = channel.start().await.expect("Channel should start");
|
|
|
|
let metadata = serde_json::json!({"chat_id": 123});
|
|
|
|
// Start typing
|
|
let _ = channel
|
|
.send_status(
|
|
crate::channels::StatusUpdate::Thinking("Processing...".into()),
|
|
&metadata,
|
|
)
|
|
.await;
|
|
assert!(channel.typing_task.read().await.is_some());
|
|
|
|
// Send Done status
|
|
let _ = channel
|
|
.send_status(
|
|
crate::channels::StatusUpdate::Status("Done".into()),
|
|
&metadata,
|
|
)
|
|
.await;
|
|
|
|
// Typing task should be cancelled
|
|
assert!(channel.typing_task.read().await.is_none());
|
|
|
|
channel.shutdown().await.expect("Shutdown should succeed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_typing_task_replaced_on_new_thinking() {
|
|
let channel = create_test_channel();
|
|
let _stream = channel.start().await.expect("Channel should start");
|
|
|
|
let metadata = serde_json::json!({"chat_id": 123});
|
|
|
|
// Start typing
|
|
let _ = channel
|
|
.send_status(
|
|
crate::channels::StatusUpdate::Thinking("First...".into()),
|
|
&metadata,
|
|
)
|
|
.await;
|
|
|
|
// Get handle of first task
|
|
let first_handle = {
|
|
let guard = channel.typing_task.read().await;
|
|
guard.as_ref().map(|h| h.id())
|
|
};
|
|
assert!(first_handle.is_some());
|
|
|
|
// Start typing again (should replace the previous task)
|
|
let _ = channel
|
|
.send_status(
|
|
crate::channels::StatusUpdate::Thinking("Second...".into()),
|
|
&metadata,
|
|
)
|
|
.await;
|
|
|
|
// Should still have a typing task, but it's a new one
|
|
let second_handle = {
|
|
let guard = channel.typing_task.read().await;
|
|
guard.as_ref().map(|h| h.id())
|
|
};
|
|
assert!(second_handle.is_some());
|
|
// The task IDs should differ (old one was aborted, new one spawned)
|
|
assert_ne!(first_handle, second_handle);
|
|
|
|
channel.shutdown().await.expect("Shutdown should succeed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_respond_cancels_typing_task() {
|
|
use crate::channels::IncomingMessage;
|
|
|
|
let channel = create_test_channel();
|
|
let _stream = channel.start().await.expect("Channel should start");
|
|
|
|
let metadata = serde_json::json!({"chat_id": 123});
|
|
|
|
// Start typing
|
|
let _ = channel
|
|
.send_status(
|
|
crate::channels::StatusUpdate::Thinking("Processing...".into()),
|
|
&metadata,
|
|
)
|
|
.await;
|
|
assert!(channel.typing_task.read().await.is_some());
|
|
|
|
// Respond should cancel the typing task
|
|
let msg = IncomingMessage::new("test", "user1", "hello").with_metadata(metadata);
|
|
let _ = channel
|
|
.respond(&msg, crate::channels::OutgoingResponse::text("response"))
|
|
.await;
|
|
|
|
// Typing task should be gone
|
|
assert!(channel.typing_task.read().await.is_none());
|
|
|
|
channel.shutdown().await.expect("Shutdown should succeed");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_stream_chunk_is_noop() {
|
|
let channel = create_test_channel();
|
|
let _stream = channel.start().await.expect("Channel should start");
|
|
|
|
let metadata = serde_json::json!({"chat_id": 123});
|
|
|
|
// StreamChunk should not start a typing task
|
|
let result = channel
|
|
.send_status(
|
|
crate::channels::StatusUpdate::StreamChunk("chunk".into()),
|
|
&metadata,
|
|
)
|
|
.await;
|
|
assert!(result.is_ok());
|
|
assert!(channel.typing_task.read().await.is_none());
|
|
|
|
channel.shutdown().await.expect("Shutdown should succeed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_status_to_wit_thinking() {
|
|
use super::status_to_wit;
|
|
|
|
let metadata = serde_json::json!({"chat_id": 42});
|
|
let wit = status_to_wit(
|
|
&crate::channels::StatusUpdate::Thinking("Processing...".into()),
|
|
&metadata,
|
|
);
|
|
|
|
assert!(matches!(
|
|
wit.status,
|
|
super::wit_channel::StatusType::Thinking
|
|
));
|
|
assert_eq!(wit.message, "Processing...");
|
|
assert!(wit.metadata_json.contains("42"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_status_to_wit_done() {
|
|
use super::status_to_wit;
|
|
|
|
let metadata = serde_json::json!(null);
|
|
let wit = status_to_wit(
|
|
&crate::channels::StatusUpdate::Status("Done".into()),
|
|
&metadata,
|
|
);
|
|
|
|
assert!(matches!(wit.status, super::wit_channel::StatusType::Done));
|
|
}
|
|
|
|
#[test]
|
|
fn test_status_to_wit_interrupted() {
|
|
use super::status_to_wit;
|
|
|
|
let metadata = serde_json::json!(null);
|
|
let wit = status_to_wit(
|
|
&crate::channels::StatusUpdate::Status("Interrupted".into()),
|
|
&metadata,
|
|
);
|
|
|
|
assert!(matches!(
|
|
wit.status,
|
|
super::wit_channel::StatusType::Interrupted
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_clone_wit_status_update() {
|
|
use super::{clone_wit_status_update, wit_channel};
|
|
|
|
let original = wit_channel::StatusUpdate {
|
|
status: wit_channel::StatusType::Thinking,
|
|
message: "hello".to_string(),
|
|
metadata_json: "{\"a\":1}".to_string(),
|
|
};
|
|
|
|
let cloned = clone_wit_status_update(&original);
|
|
assert!(matches!(cloned.status, wit_channel::StatusType::Thinking));
|
|
assert_eq!(cloned.message, "hello");
|
|
assert_eq!(cloned.metadata_json, "{\"a\":1}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_redact_credentials_replaces_values() {
|
|
use super::ChannelStoreData;
|
|
|
|
let mut creds = std::collections::HashMap::new();
|
|
creds.insert(
|
|
"TELEGRAM_BOT_TOKEN".to_string(),
|
|
"8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis".to_string(),
|
|
);
|
|
creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string());
|
|
|
|
let store =
|
|
ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds);
|
|
|
|
let error = "HTTP request failed: error sending request for url \
|
|
(https://api.telegram.org/bot8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis/getUpdates)";
|
|
|
|
let redacted = store.redact_credentials(error);
|
|
|
|
assert!(
|
|
!redacted.contains("8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis"),
|
|
"credential value should be redacted"
|
|
);
|
|
assert!(
|
|
redacted.contains("[REDACTED:TELEGRAM_BOT_TOKEN]"),
|
|
"redacted text should contain placeholder name"
|
|
);
|
|
assert!(
|
|
!redacted.contains("s3cret"),
|
|
"other credentials should also be redacted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_redact_credentials_no_op_without_credentials() {
|
|
use super::ChannelStoreData;
|
|
|
|
let store = ChannelStoreData::new(
|
|
1024 * 1024,
|
|
"test",
|
|
ChannelCapabilities::default(),
|
|
std::collections::HashMap::new(),
|
|
);
|
|
|
|
let input = "some error message";
|
|
assert_eq!(store.redact_credentials(input), input);
|
|
}
|
|
|
|
#[test]
|
|
fn test_redact_credentials_skips_empty_values() {
|
|
use super::ChannelStoreData;
|
|
|
|
let mut creds = std::collections::HashMap::new();
|
|
creds.insert("EMPTY_TOKEN".to_string(), String::new());
|
|
|
|
let store =
|
|
ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds);
|
|
|
|
let input = "should not match anything";
|
|
assert_eq!(store.redact_credentials(input), input);
|
|
}
|
|
|
|
/// Verify that the block_on-inside-spawn_blocking pattern used by the WASM
|
|
/// channel HTTP host function doesn't deadlock or panic.
|
|
#[tokio::test]
|
|
async fn test_block_on_inside_spawn_blocking_does_not_deadlock() {
|
|
let result = tokio::task::spawn_blocking(|| {
|
|
tokio::runtime::Handle::current().block_on(async { 42 })
|
|
})
|
|
.await
|
|
.expect("spawn_blocking panicked");
|
|
assert_eq!(result, 42);
|
|
}
|
|
}
|