mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
Fix/prod build (#51)
* chore: add CI secrets management and local testing script - Added ci-secrets.example.json to provide a template for CI secrets configuration. - Introduced test-ci-local.sh script to facilitate local testing of the package-and-publish workflow using act. - Updated .gitignore to exclude the actual ci-secrets.json file containing sensitive tokens. * chore: enhance local testing and environment loading scripts - Added scripts to load environment variables from .env and JSON files, improving local development setup. - Introduced ci-event.json to simulate GitHub event payloads for local CI testing. - Updated test-ci-local.sh to utilize the new event JSON for better integration with local testing workflows. - Modified .gitignore to include ci-secrets.local.json for local secret management. * chore: enhance environment loading and improve V8 memory management - Updated load-env.sh to conditionally load ci-secrets.local.json and set APPLE_PASSWORD for notarization. - Introduced a delay in auto-starting skills to prevent memory spikes during initialization. - Adjusted V8 runtime memory settings to minimize initial heap allocation, reducing the risk of OOM errors. * chore: update version bump workflow to modify tauri.conf.json - Changed the version update process to reflect changes in tauri.conf.json instead of Cargo.toml. - Adjusted the commit message to indicate the inclusion of tauri.conf.json in the version bump process. * chore: migrate from V8 to QuickJS runtime and update related configurations - Replaced V8 runtime references with QuickJS throughout the codebase, including updates to initialization and error handling. - Modified package.json to change the build command for macOS to source environment variables correctly. - Updated Cargo.toml to remove deno_core dependency and include rquickjs with appropriate features. - Cleaned up unused V8-related code and comments in the runtime modules. - Adjusted skill manifest checks to support QuickJS compatibility. * refactor: implement QuickJS runtime and remove V8 references - Replaced V8 engine with QuickJS in the runtime module, updating related imports and initialization logic. - Introduced new `qjs_engine.rs` and `qjs_skill_instance.rs` files to handle QuickJS-specific functionality. - Removed the deprecated `v8_skill_instance.rs` and associated V8-related code. - Updated JavaScript bootstrap code to align with QuickJS operations and APIs. - Adjusted documentation and comments to reflect the transition to QuickJS. * refactor: update IdbStorage to use parking_lot::Mutex for improved concurrency - Changed the connection management in IdbStorage from RwLock to parking_lot::Mutex for better performance. - Updated related methods to reflect the new locking mechanism, enhancing the efficiency of database operations. - Adjusted the IdbOpenResult struct to include serde::Serialize for potential serialization needs. * refactor: update QuickJS skill instance and timer management - Modified memory limit and stack size settings in QjsSkillInstance to be asynchronous, improving performance. - Cleaned up imports in qjs_ops.rs by removing unused dependencies and enhancing function definitions for clarity. - Refactored timer management functions for better readability and efficiency, including renaming and restructuring comments. * chore: update package.json scripts and clean up build.rs - Added a new script for running the Tauri app with environment variable loading. - Simplified the macOS development command to use a dedicated build script. - Removed unnecessary environment variable logging from build.rs to streamline the build process. - Cleaned up commented-out sections in the GitHub Actions workflow for Android packaging.
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
//! Custom V8 module resolver and loader for `@alphahuman/*` imports.
|
||||
//! Custom module resolver and loader for `@alphahuman/*` imports.
|
||||
//!
|
||||
//! NOTE: Currently unused. Skills access bridge APIs via globals (db, store, console)
|
||||
//! injected by v8_skill_instance.rs. This module is reserved for future ES module
|
||||
//! injected by qjs_skill_instance.rs. This module is reserved for future ES module
|
||||
//! import support (e.g., `import { db } from '@alphahuman/db'`).
|
||||
//!
|
||||
//! The globals-based approach was chosen because:
|
||||
//! - V8/deno_core shares the module loader across all contexts in the same runtime
|
||||
//! - Per-skill module loaders would require separate runtime instances
|
||||
//! - Globals are simpler and sufficient for the initial implementation
|
||||
//! - Per-skill module loaders can be added later if needed
|
||||
|
||||
@@ -86,10 +86,10 @@ impl SkillManifest {
|
||||
Self::from_json(&content)
|
||||
}
|
||||
|
||||
/// Whether this manifest declares a JavaScript runtime (V8).
|
||||
/// Accepts "v8", "javascript" for compatibility.
|
||||
/// Whether this manifest declares a JavaScript runtime (QuickJS).
|
||||
/// Accepts "v8", "javascript", "quickjs" for compatibility.
|
||||
pub fn is_javascript(&self) -> bool {
|
||||
self.runtime == "v8" || self.runtime == "javascript"
|
||||
matches!(self.runtime.as_str(), "v8" | "javascript" | "quickjs")
|
||||
}
|
||||
|
||||
/// Whether the skill is available on the current platform.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! V8 skill runtime module.
|
||||
//! QuickJS skill runtime module.
|
||||
//!
|
||||
//! Provides a persistent JavaScript execution environment for skills
|
||||
//! using the V8 engine via `deno_core`.
|
||||
//! using the QuickJS engine via `rquickjs`.
|
||||
//!
|
||||
//! Note: V8/deno_core is only available on desktop platforms.
|
||||
//! Note: The skill runtime is only available on desktop platforms.
|
||||
//! On mobile (Android/iOS), the skill runtime is disabled.
|
||||
|
||||
// Platform-agnostic modules
|
||||
@@ -13,7 +13,7 @@ pub mod preferences;
|
||||
pub mod socket_manager;
|
||||
pub mod types;
|
||||
|
||||
// V8/deno_core modules - desktop only (no prebuilt binaries for Android/iOS)
|
||||
// QuickJS modules - desktop only (not available on Android/iOS)
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub mod bridge;
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
@@ -21,6 +21,6 @@ pub mod cron_scheduler;
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub mod skill_registry;
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub mod v8_engine;
|
||||
pub mod qjs_engine;
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub mod v8_skill_instance;
|
||||
pub mod qjs_skill_instance;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! RuntimeEngine — top-level orchestrator for the V8 skill runtime.
|
||||
//! RuntimeEngine — top-level orchestrator for the QuickJS skill runtime.
|
||||
//!
|
||||
//! Manages skill lifecycle and provides the public API consumed by Tauri commands.
|
||||
//! Uses V8 (via deno_core) for JavaScript execution.
|
||||
//! Uses QuickJS (via rquickjs) for JavaScript execution.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -14,10 +14,10 @@ use crate::runtime::manifest::SkillManifest;
|
||||
use crate::runtime::preferences::PreferencesStore;
|
||||
use crate::runtime::skill_registry::SkillRegistry;
|
||||
use crate::runtime::types::{events, SkillSnapshot, SkillStatus, ToolResult};
|
||||
use crate::runtime::v8_skill_instance::{BridgeDeps, V8SkillInstance};
|
||||
use crate::runtime::qjs_skill_instance::{BridgeDeps, QjsSkillInstance};
|
||||
use crate::services::tdlib_v8::storage::IdbStorage;
|
||||
|
||||
/// The central runtime engine using V8.
|
||||
/// The central runtime engine using QuickJS.
|
||||
pub struct RuntimeEngine {
|
||||
/// Registry of all skills.
|
||||
registry: Arc<SkillRegistry>,
|
||||
@@ -43,7 +43,7 @@ impl RuntimeEngine {
|
||||
cron_scheduler.set_registry(Arc::clone(®istry));
|
||||
let preferences = Arc::new(PreferencesStore::new(&skills_data_dir));
|
||||
|
||||
log::info!("[runtime] V8 RuntimeEngine created");
|
||||
log::info!("[runtime] QuickJS RuntimeEngine created");
|
||||
|
||||
Ok(Self {
|
||||
registry,
|
||||
@@ -111,9 +111,7 @@ impl RuntimeEngine {
|
||||
}
|
||||
|
||||
// 4. Production: bundled resources
|
||||
// Tauri converts "../" to "_up_/" when bundling resources with array notation
|
||||
if let Some(resource_dir) = self.resource_dir.read().as_ref() {
|
||||
// Try: $RESOURCE/_up_/skills/skills/ (array notation with ../)
|
||||
let bundled_skills = resource_dir.join("_up_").join("skills").join("skills");
|
||||
if bundled_skills.exists() {
|
||||
log::info!(
|
||||
@@ -123,7 +121,6 @@ impl RuntimeEngine {
|
||||
return Ok(bundled_skills);
|
||||
}
|
||||
|
||||
// Try: $RESOURCE/skills/ (map notation)
|
||||
let bundled_skills_alt = resource_dir.join("skills");
|
||||
if bundled_skills_alt.exists() {
|
||||
log::info!(
|
||||
@@ -140,7 +137,7 @@ impl RuntimeEngine {
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Final fallback: app data dir (for user-installed skills)
|
||||
// 5. Final fallback: app data dir
|
||||
let prod_dir = self.skills_data_dir.clone();
|
||||
log::info!(
|
||||
"[runtime] Skills source dir (data dir fallback): {:?}",
|
||||
@@ -184,7 +181,6 @@ impl RuntimeEngine {
|
||||
);
|
||||
}
|
||||
Ok(_) => {
|
||||
// Not a JavaScript skill, skip
|
||||
log::info!(
|
||||
"[runtime] Skipping skill '{}': not a JavaScript skill",
|
||||
manifest_path.display()
|
||||
@@ -227,7 +223,7 @@ impl RuntimeEngine {
|
||||
let manifest = SkillManifest::from_path(&manifest_path).await?;
|
||||
if !manifest.is_javascript() {
|
||||
return Err(format!(
|
||||
"Skill '{}' uses runtime '{}', not 'v8' or 'javascript'",
|
||||
"Skill '{}' uses runtime '{}', not a supported JavaScript runtime",
|
||||
skill_id, manifest.runtime
|
||||
));
|
||||
}
|
||||
@@ -235,15 +231,15 @@ impl RuntimeEngine {
|
||||
let config = manifest.to_config();
|
||||
let data_dir = self.skills_data_dir.join(skill_id);
|
||||
|
||||
// Create the V8 skill instance
|
||||
log::info!("[runtime] Creating V8 skill instance for '{}'", skill_id);
|
||||
// Create the QuickJS skill instance
|
||||
log::info!("[runtime] Creating QuickJS skill instance for '{}'", skill_id);
|
||||
log::info!("[runtime] Config: {:?}", config);
|
||||
log::info!("[runtime] Skill dir: {:?}", skill_dir);
|
||||
log::info!("[runtime] Data dir: {:?}", data_dir);
|
||||
let (instance, rx) = V8SkillInstance::new(config.clone(), skill_dir, data_dir.clone());
|
||||
log::info!("[runtime] V8 skill instance created for '{}'", skill_id);
|
||||
let (instance, rx) = QjsSkillInstance::new(config.clone(), skill_dir, data_dir.clone());
|
||||
log::info!("[runtime] QuickJS skill instance created for '{}'", skill_id);
|
||||
|
||||
// Bundle bridge dependencies
|
||||
// Bundle bridge dependencies (no creation lock needed for QuickJS)
|
||||
let deps = BridgeDeps {
|
||||
cron_scheduler: self.cron_scheduler.clone(),
|
||||
skill_registry: self.registry.clone(),
|
||||
@@ -265,8 +261,7 @@ impl RuntimeEngine {
|
||||
|
||||
self.emit_status_change(skill_id);
|
||||
|
||||
// Wait for initialization to complete (Running or Error status)
|
||||
// with a reasonable timeout
|
||||
// Wait for initialization to complete
|
||||
let state = instance.state.clone();
|
||||
let skill_id_owned = skill_id.to_string();
|
||||
let max_wait = std::time::Duration::from_secs(10);
|
||||
@@ -278,12 +273,10 @@ impl RuntimeEngine {
|
||||
|
||||
match current_status {
|
||||
SkillStatus::Running => {
|
||||
// Successfully started
|
||||
self.emit_status_change(&skill_id_owned);
|
||||
return Ok(instance.snapshot());
|
||||
}
|
||||
SkillStatus::Error => {
|
||||
// Initialization failed - unregister and return error
|
||||
let error_msg = state
|
||||
.read()
|
||||
.error
|
||||
@@ -297,7 +290,6 @@ impl RuntimeEngine {
|
||||
));
|
||||
}
|
||||
SkillStatus::Stopped => {
|
||||
// Skill stopped unexpectedly during init
|
||||
self.registry.unregister(&skill_id_owned);
|
||||
return Err(format!(
|
||||
"Skill '{}' stopped unexpectedly during initialization",
|
||||
@@ -305,9 +297,7 @@ impl RuntimeEngine {
|
||||
));
|
||||
}
|
||||
SkillStatus::Initializing | SkillStatus::Pending => {
|
||||
// Still initializing, continue waiting
|
||||
if start.elapsed() > max_wait {
|
||||
// Timeout - skill is taking too long
|
||||
log::warn!(
|
||||
"[runtime] Skill '{}' initialization timeout, returning current state",
|
||||
skill_id_owned
|
||||
@@ -317,7 +307,6 @@ impl RuntimeEngine {
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
}
|
||||
SkillStatus::Stopping => {
|
||||
// Unexpected state during startup
|
||||
return Err(format!(
|
||||
"Skill '{}' is in unexpected Stopping state during startup",
|
||||
skill_id_owned
|
||||
@@ -375,6 +364,7 @@ impl RuntimeEngine {
|
||||
}
|
||||
|
||||
/// Auto-start skills based on user preferences.
|
||||
/// No stagger delay needed for QuickJS (lightweight contexts).
|
||||
pub async fn auto_start_skills(&self) {
|
||||
match self.discover_skills().await {
|
||||
Ok(manifests) => {
|
||||
@@ -0,0 +1,649 @@
|
||||
//! QjsSkillInstance — manages one QuickJS context per skill.
|
||||
//!
|
||||
//! Key differences from V8 version:
|
||||
//! - QuickJS contexts are Send+Sync with `parallel` feature, so we use regular tokio::spawn (not spawn_blocking)
|
||||
//! - No V8 creation lock needed (QuickJS contexts are lightweight ~1-2MB)
|
||||
//! - No stagger delay needed between skill starts
|
||||
//! - Direct memory limits via `rt.set_memory_limit()`
|
||||
//! - Uses `ctx.eval::<T, _>(code)` instead of `runtime.execute_script()`
|
||||
//! - Simplified error handling with rquickjs::Error
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::runtime::cron_scheduler::CronScheduler;
|
||||
use crate::runtime::skill_registry::SkillRegistry;
|
||||
use crate::runtime::types::{
|
||||
SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolContent, ToolDefinition, ToolResult,
|
||||
};
|
||||
use crate::services::tdlib_v8::{qjs_ops, IdbStorage};
|
||||
|
||||
/// Dependencies passed to a skill instance for bridge installation.
|
||||
#[allow(dead_code)]
|
||||
pub struct BridgeDeps {
|
||||
pub cron_scheduler: Arc<CronScheduler>,
|
||||
pub skill_registry: Arc<SkillRegistry>,
|
||||
pub app_handle: Option<tauri::AppHandle>,
|
||||
pub data_dir: PathBuf,
|
||||
// NOTE: No v8_creation_lock - QuickJS doesn't need it
|
||||
}
|
||||
|
||||
/// Shared mutable state for a skill instance.
|
||||
pub struct SkillState {
|
||||
pub status: SkillStatus,
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
pub error: Option<String>,
|
||||
pub published_state: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Default for SkillState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: SkillStatus::Pending,
|
||||
tools: Vec::new(),
|
||||
error: None,
|
||||
published_state: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A running skill instance using QuickJS.
|
||||
pub struct QjsSkillInstance {
|
||||
pub config: SkillConfig,
|
||||
pub state: Arc<RwLock<SkillState>>,
|
||||
pub sender: mpsc::Sender<SkillMessage>,
|
||||
pub skill_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl QjsSkillInstance {
|
||||
/// Create a new QuickJS skill instance.
|
||||
pub fn new(
|
||||
config: SkillConfig,
|
||||
skill_dir: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
) -> (Self, mpsc::Receiver<SkillMessage>) {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
let instance = Self {
|
||||
config,
|
||||
state: Arc::new(RwLock::new(SkillState::default())),
|
||||
sender: tx,
|
||||
skill_dir,
|
||||
data_dir,
|
||||
};
|
||||
(instance, rx)
|
||||
}
|
||||
|
||||
/// Take a snapshot of the current skill state.
|
||||
pub fn snapshot(&self) -> SkillSnapshot {
|
||||
let state = self.state.read();
|
||||
SkillSnapshot {
|
||||
skill_id: self.config.skill_id.clone(),
|
||||
name: self.config.name.clone(),
|
||||
status: state.status,
|
||||
tools: state.tools.clone(),
|
||||
error: state.error.clone(),
|
||||
state: state.published_state.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the skill's execution loop as a tokio task.
|
||||
/// Unlike V8 (which needed spawn_blocking), QuickJS contexts are Send.
|
||||
pub fn spawn(
|
||||
&self,
|
||||
mut rx: mpsc::Receiver<SkillMessage>,
|
||||
_deps: BridgeDeps,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
let config = self.config.clone();
|
||||
let state = self.state.clone();
|
||||
let skill_dir = self.skill_dir.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Update status
|
||||
state.write().status = SkillStatus::Initializing;
|
||||
|
||||
// Create storage
|
||||
let storage = match IdbStorage::new(&data_dir) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("Failed to create storage: {e}"));
|
||||
log::error!("[skill:{}] Storage creation failed: {e}", config.skill_id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Read the entry point JS file
|
||||
let entry_path = skill_dir.join(&config.entry_point);
|
||||
let js_source = match tokio::fs::read_to_string(&entry_path).await {
|
||||
Ok(src) => src,
|
||||
Err(e) => {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("Failed to read {}: {e}", config.entry_point));
|
||||
log::error!("[skill:{}] Failed to read entry point: {e}", config.skill_id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Create QuickJS runtime with memory limits
|
||||
let rt = match rquickjs::AsyncRuntime::new() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("Failed to create QuickJS runtime: {e}"));
|
||||
log::error!("[skill:{}] Failed to create QuickJS runtime: {e}", config.skill_id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Set memory limit (config.memory_limit is in bytes)
|
||||
rt.set_memory_limit(config.memory_limit).await;
|
||||
rt.set_max_stack_size(512 * 1024).await; // 512KB stack
|
||||
|
||||
// Create context with full standard library
|
||||
let ctx = match rquickjs::AsyncContext::full(&rt).await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("Failed to create QuickJS context: {e}"));
|
||||
log::error!("[skill:{}] Failed to create context: {e}", config.skill_id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Create shared timer state
|
||||
let timer_state = Arc::new(RwLock::new(qjs_ops::TimerState::default()));
|
||||
|
||||
// Create WebSocket state
|
||||
let ws_state = Arc::new(RwLock::new(qjs_ops::WebSocketState::default()));
|
||||
|
||||
// Create published skill state (different from SkillState above)
|
||||
let published_state = Arc::new(RwLock::new(qjs_ops::SkillState::default()));
|
||||
|
||||
// Register ops and run bootstrap + skill code
|
||||
let skill_id = config.skill_id.clone();
|
||||
let init_result = ctx.with(|js_ctx| {
|
||||
// Register native ops as __ops global
|
||||
let skill_context = qjs_ops::SkillContext {
|
||||
skill_id: skill_id.clone(),
|
||||
data_dir: data_dir.clone(),
|
||||
};
|
||||
|
||||
if let Err(e) = qjs_ops::register_ops(
|
||||
&js_ctx,
|
||||
storage.clone(),
|
||||
skill_context,
|
||||
published_state.clone(),
|
||||
timer_state.clone(),
|
||||
ws_state.clone(),
|
||||
) {
|
||||
return Err(format!("Failed to register ops: {e}"));
|
||||
}
|
||||
|
||||
// Load bootstrap
|
||||
let bootstrap_code = include_str!("../services/tdlib_v8/bootstrap.js");
|
||||
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(bootstrap_code) {
|
||||
let err_str = format!("Bootstrap failed: {e}");
|
||||
return Err(err_str);
|
||||
}
|
||||
|
||||
// Set skill ID
|
||||
let bridge_code = format!(
|
||||
r#"globalThis.__skillId = "{}";"#,
|
||||
skill_id.replace('"', r#"\""#)
|
||||
);
|
||||
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(bridge_code.as_bytes()) {
|
||||
return Err(format!("Skill init failed: {e}"));
|
||||
}
|
||||
|
||||
// Execute the skill's entry point
|
||||
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(js_source.as_bytes()) {
|
||||
return Err(format!("Skill load failed: {e}"));
|
||||
}
|
||||
|
||||
// Extract tool definitions
|
||||
extract_tools(&js_ctx, &state);
|
||||
|
||||
Ok(())
|
||||
}).await;
|
||||
|
||||
if let Err(e) = init_result {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(e.clone());
|
||||
log::error!("[skill:{}] {e}", config.skill_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call init() lifecycle
|
||||
if let Err(e) = call_lifecycle(&ctx, "init").await {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("init() failed: {e}"));
|
||||
log::error!("[skill:{}] init() failed: {e}", config.skill_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute pending jobs after init (process promises)
|
||||
drive_jobs(&rt).await;
|
||||
|
||||
// Call start() lifecycle
|
||||
if let Err(e) = call_lifecycle(&ctx, "start").await {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("start() failed: {e}"));
|
||||
log::error!("[skill:{}] start() failed: {e}", config.skill_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute pending jobs after start
|
||||
drive_jobs(&rt).await;
|
||||
|
||||
// Mark as running
|
||||
state.write().status = SkillStatus::Running;
|
||||
log::info!("[skill:{}] Running (QuickJS)", config.skill_id);
|
||||
|
||||
// Run the event loop
|
||||
run_event_loop(&rt, &ctx, &mut rx, &state, &config.skill_id, &timer_state).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event Loop
|
||||
// ============================================================================
|
||||
|
||||
/// The main event loop that drives the QuickJS runtime.
|
||||
/// This continuously:
|
||||
/// 1. Polls for ready timers and fires their callbacks
|
||||
/// 2. Checks for incoming messages (non-blocking)
|
||||
/// 3. Runs the QuickJS job queue for promises/async ops
|
||||
/// 4. Sleeps efficiently when idle
|
||||
async fn run_event_loop(
|
||||
rt: &rquickjs::AsyncRuntime,
|
||||
ctx: &rquickjs::AsyncContext,
|
||||
rx: &mut mpsc::Receiver<SkillMessage>,
|
||||
state: &Arc<RwLock<SkillState>>,
|
||||
skill_id: &str,
|
||||
timer_state: &Arc<RwLock<qjs_ops::TimerState>>,
|
||||
) {
|
||||
// Maximum sleep duration when no timers are pending
|
||||
const MAX_IDLE_SLEEP: Duration = Duration::from_millis(100);
|
||||
// Minimum sleep to prevent busy-spinning
|
||||
const MIN_SLEEP: Duration = Duration::from_millis(1);
|
||||
|
||||
loop {
|
||||
// 1. Poll and fire ready timers
|
||||
let ready_timers = {
|
||||
let (ready, _next) = qjs_ops::poll_timers(timer_state);
|
||||
ready
|
||||
};
|
||||
|
||||
// Fire timer callbacks in JavaScript
|
||||
for timer_id in ready_timers {
|
||||
fire_timer_callback(ctx, timer_id).await;
|
||||
}
|
||||
|
||||
// 2. Check for incoming messages (non-blocking)
|
||||
match rx.try_recv() {
|
||||
Ok(msg) => {
|
||||
let should_stop = handle_message(ctx, msg, state, skill_id).await;
|
||||
if should_stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Empty) => {
|
||||
// No message - that's fine
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
// Channel closed, exit
|
||||
log::info!("[skill:{}] Message channel disconnected, stopping", skill_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Drive QuickJS job queue (process pending promises)
|
||||
drive_jobs(rt).await;
|
||||
|
||||
// 4. Calculate sleep duration based on next timer
|
||||
let sleep_duration = {
|
||||
let (_, next_timer) = qjs_ops::poll_timers(timer_state);
|
||||
match next_timer {
|
||||
Some(d) if d < MIN_SLEEP => MIN_SLEEP,
|
||||
Some(d) if d > MAX_IDLE_SLEEP => MAX_IDLE_SLEEP,
|
||||
Some(d) => d,
|
||||
None => MAX_IDLE_SLEEP,
|
||||
}
|
||||
};
|
||||
|
||||
// Sleep efficiently - this yields the thread when no work is needed
|
||||
tokio::time::sleep(sleep_duration).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the QuickJS job queue until no more jobs are pending.
|
||||
async fn drive_jobs(rt: &rquickjs::AsyncRuntime) {
|
||||
// idle() runs all pending futures and jobs
|
||||
rt.idle().await;
|
||||
}
|
||||
|
||||
/// Fire a timer callback in JavaScript.
|
||||
async fn fire_timer_callback(ctx: &rquickjs::AsyncContext, timer_id: u32) {
|
||||
let code = format!("globalThis.__handleTimer({});", timer_id);
|
||||
ctx.with(|js_ctx| {
|
||||
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(code.as_bytes()) {
|
||||
log::error!("[timer] Callback for timer {} failed: {}", timer_id, e);
|
||||
}
|
||||
}).await;
|
||||
}
|
||||
|
||||
/// Handle a single message from the channel.
|
||||
/// Returns true if the skill should stop.
|
||||
async fn handle_message(
|
||||
ctx: &rquickjs::AsyncContext,
|
||||
msg: SkillMessage,
|
||||
state: &Arc<RwLock<SkillState>>,
|
||||
skill_id: &str,
|
||||
) -> bool {
|
||||
match msg {
|
||||
SkillMessage::CallTool { tool_name, arguments, reply } => {
|
||||
let result = handle_tool_call(ctx, &tool_name, arguments).await;
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::ServerEvent { event, data } => {
|
||||
let _ = handle_server_event(ctx, &event, data).await;
|
||||
}
|
||||
SkillMessage::CronTrigger { schedule_id } => {
|
||||
let _ = handle_cron_trigger(ctx, &schedule_id).await;
|
||||
}
|
||||
SkillMessage::Stop { reply } => {
|
||||
let _ = call_lifecycle(ctx, "stop").await;
|
||||
state.write().status = SkillStatus::Stopped;
|
||||
log::info!("[skill:{}] Stopped", skill_id);
|
||||
let _ = reply.send(());
|
||||
return true; // Signal to stop
|
||||
}
|
||||
SkillMessage::SetupStart { reply } => {
|
||||
let result = handle_js_call(ctx, "onSetupStart", "{}").await;
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::SetupSubmit { step_id, values, reply } => {
|
||||
let args = serde_json::json!({ "stepId": step_id, "values": values });
|
||||
let result = handle_js_call(ctx, "onSetupSubmit", &args.to_string()).await;
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::SetupCancel { reply } => {
|
||||
let result = handle_js_void_call(ctx, "onSetupCancel", "{}").await;
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::ListOptions { reply } => {
|
||||
let result = handle_js_call(ctx, "onListOptions", "{}").await;
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::SetOption { name, value, reply } => {
|
||||
let args = serde_json::json!({ "name": name, "value": value });
|
||||
let result = handle_js_void_call(ctx, "onSetOption", &args.to_string()).await;
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::SessionStart { session_id, reply } => {
|
||||
let args = serde_json::json!({ "sessionId": session_id });
|
||||
let result = handle_js_void_call(ctx, "onSessionStart", &args.to_string()).await;
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::SessionEnd { session_id, reply } => {
|
||||
let args = serde_json::json!({ "sessionId": session_id });
|
||||
let result = handle_js_void_call(ctx, "onSessionEnd", &args.to_string()).await;
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::Tick { reply } => {
|
||||
let result = handle_js_void_call(ctx, "onTick", "{}").await;
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::Rpc { method, params, reply } => {
|
||||
let args = serde_json::json!({ "method": method, "params": params });
|
||||
let result = handle_js_call(ctx, "onRpc", &args.to_string()).await;
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
}
|
||||
false // Don't stop
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Call a lifecycle function on the skill object.
|
||||
/// Looks for the skill at globalThis.__skill.default first, then falls back to globalThis.
|
||||
async fn call_lifecycle(ctx: &rquickjs::AsyncContext, name: &str) -> Result<(), String> {
|
||||
let name = name.to_string();
|
||||
ctx.with(|js_ctx| {
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || globalThis);
|
||||
if (typeof skill.{name} === 'function') {{
|
||||
skill.{name}();
|
||||
}} else if (typeof globalThis.{name} === 'function') {{
|
||||
globalThis.{name}();
|
||||
}}
|
||||
}})()"#
|
||||
);
|
||||
js_ctx.eval::<rquickjs::Value, _>(code.as_bytes())
|
||||
.map_err(|e| format!("{name}() failed: {e}"))?;
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
|
||||
/// Extract tool definitions from the skill.
|
||||
fn extract_tools(js_ctx: &rquickjs::Ctx<'_>, state: &Arc<RwLock<SkillState>>) {
|
||||
let code = r#"
|
||||
(function() {
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || null);
|
||||
var tools = (skill && skill.tools) || globalThis.tools || [];
|
||||
return JSON.stringify(tools.map(function(t) {
|
||||
return {
|
||||
name: t.name || "",
|
||||
description: t.description || "",
|
||||
input_schema: t.inputSchema || t.input_schema || {}
|
||||
};
|
||||
}));
|
||||
})()
|
||||
"#;
|
||||
|
||||
// eval with String type hint tells rquickjs to convert the result to a Rust String
|
||||
match js_ctx.eval::<String, _>(code) {
|
||||
Ok(json_str) => {
|
||||
match serde_json::from_str::<Vec<ToolDefinition>>(&json_str) {
|
||||
Ok(tools) => {
|
||||
state.write().tools = tools;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[tools] Failed to parse tools JSON: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[tools] Failed to extract tools: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a tool call.
|
||||
async fn handle_tool_call(
|
||||
ctx: &rquickjs::AsyncContext,
|
||||
tool_name: &str,
|
||||
arguments: serde_json::Value,
|
||||
) -> Result<ToolResult, String> {
|
||||
let args_str = serde_json::to_string(&arguments)
|
||||
.map_err(|e| format!("Failed to serialize args: {e}"))?;
|
||||
let tool_name = tool_name.to_string();
|
||||
|
||||
let result_text = ctx.with(|js_ctx| {
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || null);
|
||||
var tools = (skill && skill.tools) || globalThis.tools || [];
|
||||
for (var i = 0; i < tools.length; i++) {{
|
||||
if (tools[i].name === "{}") {{
|
||||
var args = {};
|
||||
var result = tools[i].execute(args);
|
||||
if (result && typeof result === 'object') {{
|
||||
return JSON.stringify(result);
|
||||
}}
|
||||
return String(result);
|
||||
}}
|
||||
}}
|
||||
throw new Error("Tool '{}' not found");
|
||||
}})()"#,
|
||||
tool_name.replace('"', r#"\""#),
|
||||
args_str,
|
||||
tool_name.replace('"', r#"\""#),
|
||||
);
|
||||
|
||||
match js_ctx.eval::<String, _>(code.as_bytes()) {
|
||||
Ok(s) => Ok(s),
|
||||
Err(e) => Err(format!("Tool execution failed: {e}"))
|
||||
}
|
||||
}).await?;
|
||||
|
||||
Ok(ToolResult {
|
||||
content: vec![ToolContent::Text { text: result_text }],
|
||||
is_error: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Handle a server event.
|
||||
async fn handle_server_event(
|
||||
ctx: &rquickjs::AsyncContext,
|
||||
event: &str,
|
||||
data: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let data_str = serde_json::to_string(&data).unwrap_or_else(|_| "null".to_string());
|
||||
let event = event.to_string();
|
||||
|
||||
ctx.with(|js_ctx| {
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || globalThis);
|
||||
if (typeof skill.onServerEvent === 'function') {{
|
||||
skill.onServerEvent("{}", {});
|
||||
}} else if (typeof globalThis.onServerEvent === 'function') {{
|
||||
globalThis.onServerEvent("{}", {});
|
||||
}}
|
||||
}})()"#,
|
||||
event.replace('"', r#"\""#),
|
||||
data_str,
|
||||
event.replace('"', r#"\""#),
|
||||
data_str,
|
||||
);
|
||||
|
||||
js_ctx.eval::<rquickjs::Value, _>(code.as_bytes())
|
||||
.map_err(|e| format!("Event handler failed: {e}"))?;
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
|
||||
/// Handle a cron trigger.
|
||||
async fn handle_cron_trigger(ctx: &rquickjs::AsyncContext, schedule_id: &str) -> Result<(), String> {
|
||||
let schedule_id = schedule_id.to_string();
|
||||
ctx.with(|js_ctx| {
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || globalThis);
|
||||
if (typeof skill.onCronTrigger === 'function') {{
|
||||
skill.onCronTrigger("{}");
|
||||
}} else if (typeof globalThis.onCronTrigger === 'function') {{
|
||||
globalThis.onCronTrigger("{}");
|
||||
}}
|
||||
}})()"#,
|
||||
schedule_id.replace('"', r#"\""#),
|
||||
schedule_id.replace('"', r#"\""#),
|
||||
);
|
||||
js_ctx.eval::<rquickjs::Value, _>(code.as_bytes())
|
||||
.map_err(|e| format!("Cron trigger failed: {e}"))
|
||||
.map(|_| ())
|
||||
}).await
|
||||
}
|
||||
|
||||
/// Call a JS function on the skill object that returns a JSON value.
|
||||
async fn handle_js_call(
|
||||
ctx: &rquickjs::AsyncContext,
|
||||
fn_name: &str,
|
||||
args_json: &str,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let fn_name = fn_name.to_string();
|
||||
let args_json = args_json.to_string();
|
||||
|
||||
let result_text = ctx.with(|js_ctx| {
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || globalThis);
|
||||
var fn = skill.{fn_name} || globalThis.{fn_name};
|
||||
if (typeof fn === 'function') {{
|
||||
var args = {args_json};
|
||||
var result = fn.call(skill, args);
|
||||
return JSON.stringify(result);
|
||||
}}
|
||||
return "null";
|
||||
}})()"#
|
||||
);
|
||||
|
||||
match js_ctx.eval::<String, _>(code.as_bytes()) {
|
||||
Ok(s) => Ok(s),
|
||||
Err(e) => Err(format!("{fn_name}() failed: {e}"))
|
||||
}
|
||||
}).await?;
|
||||
|
||||
serde_json::from_str(&result_text)
|
||||
.map_err(|e| format!("{fn_name}() returned invalid JSON: {e}"))
|
||||
}
|
||||
|
||||
/// Call a JS function on the skill object that returns void.
|
||||
async fn handle_js_void_call(
|
||||
ctx: &rquickjs::AsyncContext,
|
||||
fn_name: &str,
|
||||
args_json: &str,
|
||||
) -> Result<(), String> {
|
||||
let fn_name = fn_name.to_string();
|
||||
let args_json = args_json.to_string();
|
||||
|
||||
ctx.with(|js_ctx| {
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || globalThis);
|
||||
var fn = skill.{fn_name} || globalThis.{fn_name};
|
||||
if (typeof fn === 'function') {{
|
||||
var args = {args_json};
|
||||
fn.call(skill, args);
|
||||
}}
|
||||
}})()"#
|
||||
);
|
||||
|
||||
js_ctx.eval::<rquickjs::Value, _>(code.as_bytes())
|
||||
.map_err(|e| format!("{fn_name}() failed: {e}"))
|
||||
.map(|_| ())
|
||||
}).await
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
use parking_lot::RwLock;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::runtime::v8_skill_instance::SkillState;
|
||||
use crate::runtime::qjs_skill_instance::SkillState;
|
||||
use crate::runtime::types::{
|
||||
SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolDefinition, ToolResult,
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ use rust_socketio::{
|
||||
Event, Payload,
|
||||
};
|
||||
|
||||
// SkillRegistry only available on desktop (V8/deno_core required)
|
||||
// SkillRegistry only available on desktop (QuickJS required)
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
use crate::runtime::skill_registry::SkillRegistry;
|
||||
|
||||
|
||||
@@ -1,712 +0,0 @@
|
||||
//! V8SkillInstance — manages one V8 context per skill.
|
||||
//!
|
||||
//! Each skill runs on its own dedicated thread (V8's JsRuntime is not Send)
|
||||
//! with:
|
||||
//! - A scoped SQLite database
|
||||
//! - Bridge globals (db, store, net, platform, console)
|
||||
//! - An async event loop that drives timers, promises, and handles messages
|
||||
//! - Lifecycle hooks: init() -> start() -> [event loop] -> stop()
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use deno_core::{v8, JsRuntime, PollEventLoopOptions, RuntimeOptions};
|
||||
use parking_lot::RwLock;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::runtime::cron_scheduler::CronScheduler;
|
||||
use crate::runtime::skill_registry::SkillRegistry;
|
||||
use crate::runtime::types::{
|
||||
SkillConfig, SkillMessage, SkillSnapshot, SkillStatus, ToolContent, ToolDefinition, ToolResult,
|
||||
};
|
||||
use crate::services::tdlib_v8::{ops, IdbStorage};
|
||||
|
||||
/// Dependencies passed to a skill instance for bridge installation.
|
||||
/// Currently not all fields are used, but they're kept for future feature parity.
|
||||
#[allow(dead_code)]
|
||||
pub struct BridgeDeps {
|
||||
pub cron_scheduler: Arc<CronScheduler>,
|
||||
pub skill_registry: Arc<SkillRegistry>,
|
||||
pub app_handle: Option<tauri::AppHandle>,
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Shared mutable state for a skill instance.
|
||||
pub struct SkillState {
|
||||
pub status: SkillStatus,
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
pub error: Option<String>,
|
||||
pub published_state: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Default for SkillState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
status: SkillStatus::Pending,
|
||||
tools: Vec::new(),
|
||||
error: None,
|
||||
published_state: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A running skill instance using V8.
|
||||
pub struct V8SkillInstance {
|
||||
pub config: SkillConfig,
|
||||
pub state: Arc<RwLock<SkillState>>,
|
||||
pub sender: mpsc::Sender<SkillMessage>,
|
||||
pub skill_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl V8SkillInstance {
|
||||
/// Create a new V8 skill instance.
|
||||
pub fn new(
|
||||
config: SkillConfig,
|
||||
skill_dir: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
) -> (Self, mpsc::Receiver<SkillMessage>) {
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
let instance = Self {
|
||||
config,
|
||||
state: Arc::new(RwLock::new(SkillState::default())),
|
||||
sender: tx,
|
||||
skill_dir,
|
||||
data_dir,
|
||||
};
|
||||
(instance, rx)
|
||||
}
|
||||
|
||||
/// Take a snapshot of the current skill state.
|
||||
pub fn snapshot(&self) -> SkillSnapshot {
|
||||
let state = self.state.read();
|
||||
SkillSnapshot {
|
||||
skill_id: self.config.skill_id.clone(),
|
||||
name: self.config.name.clone(),
|
||||
status: state.status,
|
||||
tools: state.tools.clone(),
|
||||
error: state.error.clone(),
|
||||
state: state.published_state.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the skill's execution loop in a dedicated thread.
|
||||
/// Returns a JoinHandle wrapped in a tokio task for compatibility.
|
||||
pub fn spawn(
|
||||
&self,
|
||||
mut rx: mpsc::Receiver<SkillMessage>,
|
||||
_deps: BridgeDeps,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
let config = self.config.clone();
|
||||
let state = self.state.clone();
|
||||
let skill_dir = self.skill_dir.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
|
||||
// Use std::thread::spawn since JsRuntime is not Send
|
||||
// Wrap in tokio task for API compatibility
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Update status
|
||||
state.write().status = SkillStatus::Initializing;
|
||||
|
||||
// Create storage
|
||||
let storage = match IdbStorage::new(&data_dir) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("Failed to create storage: {e}"));
|
||||
log::error!("[skill:{}] Storage creation failed: {e}", config.skill_id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Read the entry point JS file synchronously
|
||||
let entry_path = skill_dir.join(&config.entry_point);
|
||||
let js_source = match std::fs::read_to_string(&entry_path) {
|
||||
Ok(src) => src,
|
||||
Err(e) => {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("Failed to read {}: {e}", config.entry_point));
|
||||
log::error!("[skill:{}] Failed to read entry point: {e}", config.skill_id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Create V8 runtime with memory limits to prevent OOM crashes.
|
||||
// Each skill gets its own V8 isolate; without limits V8 tries to reserve
|
||||
// ~1.4 GB per isolate which can exhaust memory when multiple skills start.
|
||||
let extension = ops::build_extension(storage.clone());
|
||||
let create_params = v8::CreateParams::default()
|
||||
.heap_limits(0, config.memory_limit);
|
||||
let mut runtime = match JsRuntime::try_new(RuntimeOptions {
|
||||
extensions: vec![extension],
|
||||
create_params: Some(create_params),
|
||||
..Default::default()
|
||||
}) {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("Failed to create V8 runtime: {e}"));
|
||||
log::error!(
|
||||
"[skill:{}] Failed to create V8 runtime (memory_limit={}): {e}",
|
||||
config.skill_id,
|
||||
config.memory_limit
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Set skill context in op state
|
||||
{
|
||||
let op_state = runtime.op_state();
|
||||
let mut state_ref = op_state.borrow_mut();
|
||||
ops::init_state_with_data_dir(
|
||||
&mut state_ref,
|
||||
storage,
|
||||
config.skill_id.clone(),
|
||||
data_dir.clone(),
|
||||
state.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
// Load bootstrap
|
||||
let bootstrap_code = include_str!("../services/tdlib_v8/bootstrap.js");
|
||||
if let Err(e) = runtime.execute_script("<bootstrap>", bootstrap_code.to_string()) {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("Bootstrap failed: {e}"));
|
||||
log::error!("[skill:{}] Bootstrap failed: {e}", config.skill_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Install skill-specific bridges
|
||||
let skill_id = config.skill_id.clone();
|
||||
let bridge_code = format!(
|
||||
r#"globalThis.__skillId = "{}";"#,
|
||||
skill_id.replace('"', r#"\""#)
|
||||
);
|
||||
|
||||
if let Err(e) = runtime.execute_script("<skill-init>", bridge_code) {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("Skill init failed: {e}"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the skill's entry point
|
||||
// Use a static string for the filename
|
||||
let filename: &'static str = Box::leak(format!("<skill:{}>", config.skill_id).into_boxed_str());
|
||||
if let Err(e) = runtime.execute_script(filename, js_source) {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("Skill load failed: {e}"));
|
||||
log::error!("[skill:{}] Load failed: {e}", config.skill_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract tool definitions
|
||||
extract_tools(&mut runtime, &state);
|
||||
|
||||
// Create a tokio runtime for this thread FIRST - all async ops must run inside it
|
||||
// This is critical because deno_unsync requires CurrentThread runtime for async ops
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Failed to create tokio runtime");
|
||||
|
||||
// Run the entire lifecycle inside the CurrentThread runtime
|
||||
rt.block_on(async {
|
||||
// Call init()
|
||||
if let Err(e) = call_lifecycle_fn_async(&mut runtime, "init").await {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("init() failed: {e}"));
|
||||
log::error!("[skill:{}] init() failed: {e}", config.skill_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Call start()
|
||||
if let Err(e) = call_lifecycle_fn_async(&mut runtime, "start").await {
|
||||
let mut s = state.write();
|
||||
s.status = SkillStatus::Error;
|
||||
s.error = Some(format!("start() failed: {e}"));
|
||||
log::error!("[skill:{}] start() failed: {e}", config.skill_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as running
|
||||
state.write().status = SkillStatus::Running;
|
||||
log::info!("[skill:{}] Running (V8)", config.skill_id);
|
||||
|
||||
// Run the event loop
|
||||
run_event_loop(&mut runtime, &mut rx, &state, &config.skill_id).await;
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event Loop
|
||||
// ============================================================================
|
||||
|
||||
/// The main event loop that drives the V8 runtime.
|
||||
/// This continuously:
|
||||
/// 1. Polls for ready timers and fires their callbacks
|
||||
/// 2. Checks for incoming messages (non-blocking)
|
||||
/// 3. Runs the V8 event loop for promises/async ops
|
||||
/// 4. Sleeps efficiently when idle
|
||||
async fn run_event_loop(
|
||||
runtime: &mut JsRuntime,
|
||||
rx: &mut mpsc::Receiver<SkillMessage>,
|
||||
state: &Arc<RwLock<SkillState>>,
|
||||
skill_id: &str,
|
||||
) {
|
||||
// Maximum sleep duration when no timers are pending
|
||||
const MAX_IDLE_SLEEP: Duration = Duration::from_millis(100);
|
||||
// Minimum sleep to prevent busy-spinning
|
||||
const MIN_SLEEP: Duration = Duration::from_millis(1);
|
||||
|
||||
loop {
|
||||
// 1. Poll and fire ready timers
|
||||
let ready_timers = {
|
||||
let op_state = runtime.op_state();
|
||||
let mut state_ref = op_state.borrow_mut();
|
||||
let (ready, _next) = ops::poll_timers(&mut state_ref);
|
||||
ready
|
||||
};
|
||||
|
||||
// Fire timer callbacks in JavaScript
|
||||
for timer_id in ready_timers {
|
||||
fire_timer_callback(runtime, timer_id);
|
||||
}
|
||||
|
||||
// 2. Check for incoming messages (non-blocking)
|
||||
match rx.try_recv() {
|
||||
Ok(msg) => {
|
||||
let should_stop = handle_message(runtime, msg, state, skill_id).await;
|
||||
if should_stop {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Empty) => {
|
||||
// No message - that's fine
|
||||
}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
// Channel closed, exit
|
||||
log::info!("[skill:{}] Message channel disconnected, stopping", skill_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Run the V8 event loop (processes promises, async ops, etc.)
|
||||
// Use poll mode - returns immediately if nothing to do
|
||||
let poll_result = runtime
|
||||
.run_event_loop(PollEventLoopOptions {
|
||||
wait_for_inspector: false,
|
||||
pump_v8_message_loop: true,
|
||||
})
|
||||
.await;
|
||||
|
||||
if let Err(e) = poll_result {
|
||||
log::error!("[skill:{}] Event loop error: {}", skill_id, e);
|
||||
// Don't break - try to continue
|
||||
}
|
||||
|
||||
// 4. Calculate sleep duration based on next timer
|
||||
let sleep_duration = {
|
||||
let op_state = runtime.op_state();
|
||||
let mut state_ref = op_state.borrow_mut();
|
||||
let (_, next_timer) = ops::poll_timers(&mut state_ref);
|
||||
match next_timer {
|
||||
Some(d) if d < MIN_SLEEP => MIN_SLEEP,
|
||||
Some(d) if d > MAX_IDLE_SLEEP => MAX_IDLE_SLEEP,
|
||||
Some(d) => d,
|
||||
None => MAX_IDLE_SLEEP,
|
||||
}
|
||||
};
|
||||
|
||||
// Sleep efficiently - this yields the thread when no work is needed
|
||||
tokio::time::sleep(sleep_duration).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire a timer callback in JavaScript.
|
||||
fn fire_timer_callback(runtime: &mut JsRuntime, timer_id: u32) {
|
||||
let code = format!("globalThis.__handleTimer({});", timer_id);
|
||||
if let Err(e) = runtime.execute_script("<timer-callback>", code) {
|
||||
log::error!("[timer] Callback for timer {} failed: {}", timer_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a single message from the channel.
|
||||
/// Returns true if the skill should stop.
|
||||
async fn handle_message(
|
||||
runtime: &mut JsRuntime,
|
||||
msg: SkillMessage,
|
||||
state: &Arc<RwLock<SkillState>>,
|
||||
skill_id: &str,
|
||||
) -> bool {
|
||||
match msg {
|
||||
SkillMessage::CallTool {
|
||||
tool_name,
|
||||
arguments,
|
||||
reply,
|
||||
} => {
|
||||
let result = handle_tool_call_sync(runtime, &tool_name, arguments);
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::ServerEvent { event, data } => {
|
||||
let _ = handle_server_event_sync(runtime, &event, data);
|
||||
}
|
||||
SkillMessage::CronTrigger { schedule_id } => {
|
||||
let _ = handle_cron_trigger_sync(runtime, &schedule_id);
|
||||
}
|
||||
SkillMessage::Stop { reply } => {
|
||||
let _ = call_lifecycle_fn_sync(runtime, "stop");
|
||||
state.write().status = SkillStatus::Stopped;
|
||||
log::info!("[skill:{}] Stopped", skill_id);
|
||||
let _ = reply.send(());
|
||||
return true; // Signal to stop
|
||||
}
|
||||
SkillMessage::SetupStart { reply } => {
|
||||
let result = handle_js_call_sync(runtime, "onSetupStart", "{}");
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::SetupSubmit {
|
||||
step_id,
|
||||
values,
|
||||
reply,
|
||||
} => {
|
||||
let args = serde_json::json!({
|
||||
"stepId": step_id,
|
||||
"values": values,
|
||||
});
|
||||
let result = handle_js_call_sync(runtime, "onSetupSubmit", &args.to_string());
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::SetupCancel { reply } => {
|
||||
let result = handle_js_void_call_sync(runtime, "onSetupCancel", "{}");
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::ListOptions { reply } => {
|
||||
let result = handle_js_call_sync(runtime, "onListOptions", "{}");
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::SetOption { name, value, reply } => {
|
||||
let args = serde_json::json!({
|
||||
"name": name,
|
||||
"value": value,
|
||||
});
|
||||
let result = handle_js_void_call_sync(runtime, "onSetOption", &args.to_string());
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::SessionStart { session_id, reply } => {
|
||||
let args = serde_json::json!({ "sessionId": session_id });
|
||||
let result = handle_js_void_call_sync(runtime, "onSessionStart", &args.to_string());
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::SessionEnd { session_id, reply } => {
|
||||
let args = serde_json::json!({ "sessionId": session_id });
|
||||
let result = handle_js_void_call_sync(runtime, "onSessionEnd", &args.to_string());
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::Tick { reply } => {
|
||||
let result = handle_js_void_call_sync(runtime, "onTick", "{}");
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::Rpc {
|
||||
method,
|
||||
params,
|
||||
reply,
|
||||
} => {
|
||||
let args = serde_json::json!({
|
||||
"method": method,
|
||||
"params": params,
|
||||
});
|
||||
let result = handle_js_call_sync(runtime, "onRpc", &args.to_string());
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
}
|
||||
false // Don't stop
|
||||
}
|
||||
|
||||
/// Extract tool definitions from skill.tools (supports both globalThis.__skill.default and globalThis.tools).
|
||||
fn extract_tools(runtime: &mut JsRuntime, state: &Arc<RwLock<SkillState>>) {
|
||||
let code = r#"
|
||||
(function() {
|
||||
// Try to get skill from bundled export first, then fall back to globalThis.tools
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || null);
|
||||
var tools = (skill && skill.tools) || globalThis.tools || [];
|
||||
return JSON.stringify(tools.map(function(t) {
|
||||
return {
|
||||
name: t.name || "",
|
||||
description: t.description || "",
|
||||
input_schema: t.inputSchema || t.input_schema || {}
|
||||
};
|
||||
}));
|
||||
})()
|
||||
"#;
|
||||
|
||||
if let Ok(result) = runtime.execute_script("<extract-tools>", code.to_string()) {
|
||||
let scope = &mut runtime.handle_scope();
|
||||
let local = v8::Local::new(scope, result);
|
||||
|
||||
if let Some(s) = local.to_string(scope) {
|
||||
let json_str = s.to_rust_string_lossy(scope);
|
||||
if let Ok(tools) = serde_json::from_str::<Vec<ToolDefinition>>(&json_str) {
|
||||
state.write().tools = tools;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Call a lifecycle function on the skill object asynchronously.
|
||||
/// This version runs inside the skill's CurrentThread runtime - no nested runtime creation.
|
||||
/// Looks for the skill at globalThis.__skill.default first, then falls back to globalThis.
|
||||
///
|
||||
/// Note: This does NOT wait for the event loop to complete, because lifecycle functions
|
||||
/// may start async operations (like update loops) that run indefinitely. The main event
|
||||
/// loop will process pending async work after init/start complete.
|
||||
async fn call_lifecycle_fn_async(runtime: &mut JsRuntime, name: &str) -> Result<(), String> {
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || globalThis);
|
||||
if (typeof skill.{name} === 'function') {{
|
||||
skill.{name}();
|
||||
}} else if (typeof globalThis.{name} === 'function') {{
|
||||
globalThis.{name}();
|
||||
}}
|
||||
}})()"#
|
||||
);
|
||||
|
||||
runtime
|
||||
.execute_script("<lifecycle>", code)
|
||||
.map_err(|e| format!("{name}() failed: {e}"))?;
|
||||
|
||||
// Don't wait for event loop here - the main event loop will handle pending async work.
|
||||
// This is important because lifecycle functions may start long-running async operations
|
||||
// (like TDLib update loops) that would block init/start from completing.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call a lifecycle function on the skill object synchronously.
|
||||
/// WARNING: This creates its own tokio runtime - only use when NOT inside an async context.
|
||||
/// Looks for the skill at globalThis.__skill.default first, then falls back to globalThis.
|
||||
#[allow(dead_code)]
|
||||
fn call_lifecycle_fn_sync(runtime: &mut JsRuntime, name: &str) -> Result<(), String> {
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || globalThis);
|
||||
if (typeof skill.{name} === 'function') {{
|
||||
skill.{name}();
|
||||
}} else if (typeof globalThis.{name} === 'function') {{
|
||||
globalThis.{name}();
|
||||
}}
|
||||
}})()"#
|
||||
);
|
||||
|
||||
runtime
|
||||
.execute_script("<lifecycle>", code)
|
||||
.map_err(|e| format!("{name}() failed: {e}"))?;
|
||||
|
||||
// Run event loop synchronously to handle any pending ops
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create runtime: {e}"))?;
|
||||
|
||||
rt.block_on(async {
|
||||
runtime
|
||||
.run_event_loop(PollEventLoopOptions::default())
|
||||
.await
|
||||
.map_err(|e| format!("Event loop error: {e}"))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a tool call synchronously (no async ops waited).
|
||||
/// This is used when we just need to invoke the tool and get immediate result.
|
||||
fn handle_tool_call_sync(
|
||||
runtime: &mut JsRuntime,
|
||||
tool_name: &str,
|
||||
arguments: serde_json::Value,
|
||||
) -> Result<ToolResult, String> {
|
||||
let args_str =
|
||||
serde_json::to_string(&arguments).map_err(|e| format!("Failed to serialize args: {e}"))?;
|
||||
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || null);
|
||||
var tools = (skill && skill.tools) || globalThis.tools || [];
|
||||
for (var i = 0; i < tools.length; i++) {{
|
||||
if (tools[i].name === "{}") {{
|
||||
var args = {};
|
||||
var result = tools[i].execute(args);
|
||||
if (result && typeof result === 'object') {{
|
||||
return JSON.stringify(result);
|
||||
}}
|
||||
return String(result);
|
||||
}}
|
||||
}}
|
||||
throw new Error("Tool '{}' not found");
|
||||
}})()"#,
|
||||
tool_name.replace('"', r#"\""#),
|
||||
args_str,
|
||||
tool_name.replace('"', r#"\""#),
|
||||
);
|
||||
|
||||
let result = runtime
|
||||
.execute_script("<tool-call>", code)
|
||||
.map_err(|e| format!("Tool execution failed: {e}"))?;
|
||||
|
||||
// Note: We don't run the event loop here because we're already inside
|
||||
// the skill's event loop. Pending async ops will be handled by the main loop.
|
||||
|
||||
let scope = &mut runtime.handle_scope();
|
||||
let local = v8::Local::new(scope, result);
|
||||
|
||||
let result_text = if let Some(s) = local.to_string(scope) {
|
||||
s.to_rust_string_lossy(scope)
|
||||
} else {
|
||||
"null".to_string()
|
||||
};
|
||||
|
||||
Ok(ToolResult {
|
||||
content: vec![ToolContent::Text { text: result_text }],
|
||||
is_error: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Handle a server event synchronously.
|
||||
fn handle_server_event_sync(
|
||||
runtime: &mut JsRuntime,
|
||||
event: &str,
|
||||
data: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
let data_str = serde_json::to_string(&data).unwrap_or_else(|_| "null".to_string());
|
||||
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || globalThis);
|
||||
if (typeof skill.onServerEvent === 'function') {{
|
||||
skill.onServerEvent("{}", {});
|
||||
}} else if (typeof globalThis.onServerEvent === 'function') {{
|
||||
globalThis.onServerEvent("{}", {});
|
||||
}}
|
||||
}})()"#,
|
||||
event.replace('"', r#"\""#),
|
||||
data_str,
|
||||
event.replace('"', r#"\""#),
|
||||
data_str,
|
||||
);
|
||||
|
||||
runtime
|
||||
.execute_script("<server-event>", code)
|
||||
.map_err(|e| format!("Event handler failed: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a cron trigger synchronously.
|
||||
fn handle_cron_trigger_sync(runtime: &mut JsRuntime, schedule_id: &str) -> Result<(), String> {
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || globalThis);
|
||||
if (typeof skill.onCronTrigger === 'function') {{
|
||||
skill.onCronTrigger("{}");
|
||||
}} else if (typeof globalThis.onCronTrigger === 'function') {{
|
||||
globalThis.onCronTrigger("{}");
|
||||
}}
|
||||
}})()"#,
|
||||
schedule_id.replace('"', r#"\""#),
|
||||
schedule_id.replace('"', r#"\""#),
|
||||
);
|
||||
|
||||
runtime
|
||||
.execute_script("<cron-trigger>", code)
|
||||
.map_err(|e| format!("Cron trigger failed: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Call a JS function on the skill object that returns a JSON value synchronously.
|
||||
fn handle_js_call_sync(
|
||||
runtime: &mut JsRuntime,
|
||||
fn_name: &str,
|
||||
args_json: &str,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || globalThis);
|
||||
var fn = skill.{fn_name} || globalThis.{fn_name};
|
||||
if (typeof fn === 'function') {{
|
||||
var args = {args_json};
|
||||
var result = fn.call(skill, args);
|
||||
return JSON.stringify(result);
|
||||
}}
|
||||
return "null";
|
||||
}})()"#
|
||||
);
|
||||
|
||||
let result = runtime
|
||||
.execute_script("<js-call>", code)
|
||||
.map_err(|e| format!("{fn_name}() failed: {e}"))?;
|
||||
|
||||
let scope = &mut runtime.handle_scope();
|
||||
let local = v8::Local::new(scope, result);
|
||||
|
||||
let result_text = if let Some(s) = local.to_string(scope) {
|
||||
s.to_rust_string_lossy(scope)
|
||||
} else {
|
||||
"null".to_string()
|
||||
};
|
||||
|
||||
serde_json::from_str(&result_text)
|
||||
.map_err(|e| format!("{fn_name}() returned invalid JSON: {e}"))
|
||||
}
|
||||
|
||||
/// Call a JS function on the skill object that returns void synchronously.
|
||||
fn handle_js_void_call_sync(
|
||||
runtime: &mut JsRuntime,
|
||||
fn_name: &str,
|
||||
args_json: &str,
|
||||
) -> Result<(), String> {
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
var skill = globalThis.__skill && globalThis.__skill.default
|
||||
? globalThis.__skill.default
|
||||
: (globalThis.__skill || globalThis);
|
||||
var fn = skill.{fn_name} || globalThis.{fn_name};
|
||||
if (typeof fn === 'function') {{
|
||||
var args = {args_json};
|
||||
fn.call(skill, args);
|
||||
}}
|
||||
}})()"#
|
||||
);
|
||||
|
||||
runtime
|
||||
.execute_script("<js-void-call>", code)
|
||||
.map_err(|e| format!("{fn_name}() failed: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user